diff --git a/.entire/insights.db b/.entire/insights.db new file mode 100644 index 000000000..ec3066e01 Binary files /dev/null and b/.entire/insights.db differ diff --git a/.entire/settings.json b/.entire/settings.json index 75d97108d..85419f597 100644 --- a/.entire/settings.json +++ b/.entire/settings.json @@ -6,6 +6,9 @@ "checkpoint_remote": { "provider": "github", "repo": "entireio/cli-checkpoints" + }, + "summarize": { + "enabled": true } } } diff --git a/.superset/config.json b/.superset/config.json new file mode 100644 index 000000000..f806b5255 --- /dev/null +++ b/.superset/config.json @@ -0,0 +1,5 @@ +{ + "setup": [], + "teardown": [], + "run": [] +} diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index c490b32dc..8ccf30ee9 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -27,7 +27,7 @@ type DeclaredCaps struct { // AsHookSupport returns the agent as HookSupport if it both implements the // interface and (for CapabilityDeclarer agents) has declared the capability. -func AsHookSupport(ag Agent) (HookSupport, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsHookSupport(ag Agent) (HookSupport, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } @@ -43,7 +43,7 @@ func AsHookSupport(ag Agent) (HookSupport, bool) { //nolint:ireturn // type-asse // AsTranscriptAnalyzer returns the agent as TranscriptAnalyzer if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. -func AsTranscriptAnalyzer(ag Agent) (TranscriptAnalyzer, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsTranscriptAnalyzer(ag Agent) (TranscriptAnalyzer, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } @@ -59,7 +59,7 @@ func AsTranscriptAnalyzer(ag Agent) (TranscriptAnalyzer, bool) { //nolint:iretur // AsTranscriptPreparer returns the agent as TranscriptPreparer if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. -func AsTranscriptPreparer(ag Agent) (TranscriptPreparer, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsTranscriptPreparer(ag Agent) (TranscriptPreparer, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } @@ -75,7 +75,7 @@ func AsTranscriptPreparer(ag Agent) (TranscriptPreparer, bool) { //nolint:iretur // AsTokenCalculator returns the agent as TokenCalculator if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. -func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } @@ -91,7 +91,7 @@ func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { //nolint:ireturn // t // AsTextGenerator returns the agent as TextGenerator if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. -func AsTextGenerator(ag Agent) (TextGenerator, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsTextGenerator(ag Agent) (TextGenerator, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } @@ -107,7 +107,7 @@ func AsTextGenerator(ag Agent) (TextGenerator, bool) { //nolint:ireturn // type- // AsHookResponseWriter returns the agent as HookResponseWriter if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. -func AsHookResponseWriter(ag Agent) (HookResponseWriter, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsHookResponseWriter(ag Agent) (HookResponseWriter, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } @@ -126,7 +126,7 @@ func AsHookResponseWriter(ag Agent) (HookResponseWriter, bool) { //nolint:iretur // ExtractPrompts is conceptually part of transcript analysis, so it shares the same // capability gate — this prevents calling extract-prompts on external agent binaries // that never declared transcript_analyzer support. -func AsPromptExtractor(ag Agent) (PromptExtractor, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsPromptExtractor(ag Agent) (PromptExtractor, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } @@ -142,7 +142,7 @@ func AsPromptExtractor(ag Agent) (PromptExtractor, bool) { //nolint:ireturn // t // AsSubagentAwareExtractor returns the agent as SubagentAwareExtractor if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. -func AsSubagentAwareExtractor(ag Agent) (SubagentAwareExtractor, bool) { //nolint:ireturn // type-assertion helper must return interface +func AsSubagentAwareExtractor(ag Agent) (SubagentAwareExtractor, bool) { //nolint:ireturn // type-assertion helper if ag == nil { return nil, false } diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 40133fbcf..8b5a2072b 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -232,6 +232,12 @@ type WriteCommittedOptions struct { // AuthorEmail is the email to use for commits AuthorEmail string + // OwnerName and OwnerEmail identify who owned the session when it started. + // Unlike AuthorName/AuthorEmail, these are durable session-attribution fields, + // not metadata-branch commit authorship. + OwnerName string + OwnerEmail string + // MetadataDir is a directory containing additional metadata files to copy // If set, all files in this directory will be copied to the checkpoint path // This is useful for copying task metadata files, subagent transcripts, etc. @@ -374,6 +380,10 @@ type CommittedMetadata struct { // Model is the LLM model used during the session (e.g., "claude-sonnet-4-20250514") Model string `json:"model,omitempty"` + // OwnerName and OwnerEmail identify who owned the session when it started. + OwnerName string `json:"owner_name,omitempty"` + OwnerEmail string `json:"owner_email,omitempty"` + // TurnID correlates checkpoints from the same agent turn. // When a turn's work spans multiple commits, each gets its own checkpoint // but they share the same TurnID for future aggregation/deduplication. diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index 06c19e9f8..58699eb7d 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -219,6 +219,61 @@ func TestWriteCommitted_AgentField(t *testing.T) { } } +func TestWriteCommitted_OwnerFieldsPersisted(t *testing.T) { + tempDir := t.TempDir() + + repo, err := git.PlainInit(tempDir, false) + require.NoError(t, err) + + worktree, err := repo.Worktree() + require.NoError(t, err) + + readmeFile := filepath.Join(tempDir, "README.md") + require.NoError(t, os.WriteFile(readmeFile, []byte("# Test"), 0o644)) + _, err = worktree.Add("README.md") + require.NoError(t, err) + _, err = worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + }) + require.NoError(t, err) + + store := NewGitStore(repo) + checkpointID := id.MustCheckpointID("b1b2c3d4e5f6") + + err = store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: checkpointID, + SessionID: "test-session-owner-meta", + Strategy: "manual-commit", + Transcript: []byte("test transcript content"), + AuthorName: "Condense Author", + AuthorEmail: "condense@example.com", + OwnerName: "Session Owner", + OwnerEmail: "owner@example.com", + }) + require.NoError(t, err) + + ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err) + commit, err := repo.CommitObject(ref.Hash()) + require.NoError(t, err) + tree, err := commit.Tree() + require.NoError(t, err) + + checkpointTree, err := tree.Tree(checkpointID.Path()) + require.NoError(t, err) + sessionTree, err := checkpointTree.Tree("0") + require.NoError(t, err) + sessionMetadataFile, err := sessionTree.File(paths.MetadataFileName) + require.NoError(t, err) + sessionContent, err := sessionMetadataFile.Contents() + require.NoError(t, err) + + var sessionMetadata CommittedMetadata + require.NoError(t, json.Unmarshal([]byte(sessionContent), &sessionMetadata)) + require.Equal(t, "Session Owner", sessionMetadata.OwnerName) + require.Equal(t, "owner@example.com", sessionMetadata.OwnerEmail) +} + // readLatestSessionMetadata reads the session-specific metadata from the latest session subdirectory. // This is where session-specific fields like Summary are stored. func readLatestSessionMetadata(t *testing.T, repo *git.Repository, checkpointID id.CheckpointID) CommittedMetadata { diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 47a75beb8..8f7e52032 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -374,6 +374,8 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom FilesTouched: opts.FilesTouched, Agent: opts.Agent, Model: opts.Model, + OwnerName: opts.OwnerName, + OwnerEmail: opts.OwnerEmail, TurnID: opts.TurnID, IsTask: opts.IsTask, ToolUseID: opts.ToolUseID, diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index 16d89543f..677714acc 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -321,6 +321,8 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions, FilesTouched: opts.FilesTouched, Agent: opts.Agent, Model: opts.Model, + OwnerName: opts.OwnerName, + OwnerEmail: opts.OwnerEmail, TurnID: opts.TurnID, IsTask: opts.IsTask, ToolUseID: opts.ToolUseID, diff --git a/cmd/entire/cli/evolve/evolve.go b/cmd/entire/cli/evolve/evolve.go new file mode 100644 index 000000000..0d8f8d15e --- /dev/null +++ b/cmd/entire/cli/evolve/evolve.go @@ -0,0 +1,28 @@ +// Package evolve implements the evolution loop that tracks sessions since the +// last improvement run and triggers suggestions after a configurable threshold. +package evolve + +import "time" + +// State tracks the evolution loop's progress. +// Stored in SQLite and mirrored to insights/evolution.json on the checkpoint branch. +type State struct { + LastRunAt time.Time `json:"last_run_at"` + SessionsSinceLastRun int `json:"sessions_since_last_run"` + TotalRuns int `json:"total_runs"` + SuggestionsGenerated int `json:"suggestions_generated"` + SuggestionsAccepted int `json:"suggestions_accepted"` +} + +// SuggestionRecord tracks a suggestion through its lifecycle. +type SuggestionRecord struct { + ID string `json:"id"` + Title string `json:"title"` + FileType string `json:"file_type"` + Priority string `json:"priority"` + Status string `json:"status"` // "pending", "accepted", "rejected" + CreatedAt time.Time `json:"created_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + PreAvgScore *float64 `json:"pre_avg_score,omitempty"` + PostAvgScore *float64 `json:"post_avg_score,omitempty"` +} diff --git a/cmd/entire/cli/evolve/notify.go b/cmd/entire/cli/evolve/notify.go new file mode 100644 index 000000000..d643314f2 --- /dev/null +++ b/cmd/entire/cli/evolve/notify.go @@ -0,0 +1,19 @@ +package evolve + +import ( + "fmt" + "io" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// CheckAndNotify increments the session counter and notifies the user +// when the evolution threshold is reached. Called after session condensation. +func CheckAndNotify(w io.Writer, config settings.EvolveSettings, state *State) { + IncrementSessionCount(state) + if !ShouldTrigger(config, *state) { + return + } + fmt.Fprintf(w, "\n Tip: %d sessions since last improvement analysis.\n", state.SessionsSinceLastRun) + fmt.Fprintln(w, " Run `entire improve` to get context file suggestions.") +} diff --git a/cmd/entire/cli/evolve/notify_test.go b/cmd/entire/cli/evolve/notify_test.go new file mode 100644 index 000000000..de4698dd5 --- /dev/null +++ b/cmd/entire/cli/evolve/notify_test.go @@ -0,0 +1,66 @@ +package evolve_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/evolve" + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +func TestCheckAndNotify_ThresholdMet(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + config := settings.EvolveSettings{Enabled: true, SessionThreshold: 5} + state := evolve.State{SessionsSinceLastRun: 4} // will become 5 after increment + + evolve.CheckAndNotify(&buf, config, &state) + + if state.SessionsSinceLastRun != 5 { + t.Errorf("expected SessionsSinceLastRun=5, got %d", state.SessionsSinceLastRun) + } + output := buf.String() + if !strings.Contains(output, "entire improve") { + t.Errorf("expected output to mention 'entire improve', got: %q", output) + } + if !strings.Contains(output, "5") { + t.Errorf("expected output to mention session count 5, got: %q", output) + } +} + +func TestCheckAndNotify_BelowThreshold(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + config := settings.EvolveSettings{Enabled: true, SessionThreshold: 5} + state := evolve.State{SessionsSinceLastRun: 2} // will become 3 after increment + + evolve.CheckAndNotify(&buf, config, &state) + + if state.SessionsSinceLastRun != 3 { + t.Errorf("expected SessionsSinceLastRun=3, got %d", state.SessionsSinceLastRun) + } + if buf.Len() != 0 { + t.Errorf("expected no output below threshold, got: %q", buf.String()) + } +} + +func TestCheckAndNotify_Disabled(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + config := settings.EvolveSettings{Enabled: false, SessionThreshold: 5} + state := evolve.State{SessionsSinceLastRun: 10} + + evolve.CheckAndNotify(&buf, config, &state) + + // Counter still increments even when disabled + if state.SessionsSinceLastRun != 11 { + t.Errorf("expected SessionsSinceLastRun=11, got %d", state.SessionsSinceLastRun) + } + if buf.Len() != 0 { + t.Errorf("expected no output when disabled, got: %q", buf.String()) + } +} diff --git a/cmd/entire/cli/evolve/tracker.go b/cmd/entire/cli/evolve/tracker.go new file mode 100644 index 000000000..917e976f5 --- /dev/null +++ b/cmd/entire/cli/evolve/tracker.go @@ -0,0 +1,78 @@ +package evolve + +import ( + "fmt" + "time" +) + +// Tracker manages the lifecycle of improvement suggestions. +type Tracker struct { + Records map[string]*SuggestionRecord +} + +func NewTracker() *Tracker { + return &Tracker{Records: make(map[string]*SuggestionRecord)} +} + +func (t *Tracker) AddSuggestion(rec SuggestionRecord) { + t.Records[rec.ID] = &rec +} + +func (t *Tracker) Get(id string) *SuggestionRecord { + return t.Records[id] +} + +// Accept marks a suggestion as accepted and records the resolution time. +func (t *Tracker) Accept(id string) error { + return t.resolve(id, "accepted") +} + +// Reject marks a suggestion as rejected and records the resolution time. +func (t *Tracker) Reject(id string) error { + return t.resolve(id, "rejected") +} + +func (t *Tracker) resolve(id, status string) error { + rec, ok := t.Records[id] + if !ok { + return fmt.Errorf("suggestion %q not found", id) + } + now := time.Now() + rec.Status = status + rec.ResolvedAt = &now + return nil +} + +// MeasureImpact sets the pre/post average scores for impact analysis. +// Returns the updated record, or nil if the ID is not found. +func (t *Tracker) MeasureImpact(id string, scoresBefore, scoresAfter []float64) *SuggestionRecord { + rec, ok := t.Records[id] + if !ok { + return nil + } + rec.PreAvgScore = avgOrNil(scoresBefore) + rec.PostAvgScore = avgOrNil(scoresAfter) + return rec +} + +func (t *Tracker) Pending() []SuggestionRecord { + var result []SuggestionRecord + for _, rec := range t.Records { + if rec.Status == "pending" { + result = append(result, *rec) + } + } + return result +} + +func avgOrNil(scores []float64) *float64 { + if len(scores) == 0 { + return nil + } + var sum float64 + for _, s := range scores { + sum += s + } + avg := sum / float64(len(scores)) + return &avg +} diff --git a/cmd/entire/cli/evolve/tracker_test.go b/cmd/entire/cli/evolve/tracker_test.go new file mode 100644 index 000000000..34e73a904 --- /dev/null +++ b/cmd/entire/cli/evolve/tracker_test.go @@ -0,0 +1,185 @@ +package evolve_test + +import ( + "math" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/evolve" +) + +func TestTracker_AddAndGet(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + rec := evolve.SuggestionRecord{ + ID: "rec-1", + Title: "Add lint instructions", + FileType: "CLAUDE.md", + Priority: "high", + Status: "pending", + CreatedAt: time.Now(), + } + tr.AddSuggestion(rec) + + got := tr.Get("rec-1") + if got == nil { + t.Fatal("expected to find record by ID, got nil") + } + if got.Title != "Add lint instructions" { + t.Errorf("expected Title=%q, got %q", "Add lint instructions", got.Title) + } +} + +func TestTracker_Accept(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + tr.AddSuggestion(evolve.SuggestionRecord{ + ID: "rec-2", + Status: "pending", + CreatedAt: time.Now(), + }) + + before := time.Now() + if err := tr.Accept("rec-2"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + after := time.Now() + + got := tr.Get("rec-2") + if got.Status != "accepted" { + t.Errorf("expected Status=%q, got %q", "accepted", got.Status) + } + if got.ResolvedAt == nil { + t.Fatal("expected ResolvedAt to be set") + } + if got.ResolvedAt.Before(before) || got.ResolvedAt.After(after) { + t.Errorf("expected ResolvedAt to be approximately now, got %v", got.ResolvedAt) + } +} + +func TestTracker_Reject(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + tr.AddSuggestion(evolve.SuggestionRecord{ + ID: "rec-3", + Status: "pending", + CreatedAt: time.Now(), + }) + + before := time.Now() + if err := tr.Reject("rec-3"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + after := time.Now() + + got := tr.Get("rec-3") + if got.Status != "rejected" { + t.Errorf("expected Status=%q, got %q", "rejected", got.Status) + } + if got.ResolvedAt == nil { + t.Fatal("expected ResolvedAt to be set") + } + if got.ResolvedAt.Before(before) || got.ResolvedAt.After(after) { + t.Errorf("expected ResolvedAt to be approximately now, got %v", got.ResolvedAt) + } +} + +func TestTracker_AcceptNotFound(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + + if err := tr.Accept("nonexistent"); err == nil { + t.Error("expected error for unknown ID, got nil") + } +} + +func TestTracker_RejectNotFound(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + + if err := tr.Reject("nonexistent"); err == nil { + t.Error("expected error for unknown ID, got nil") + } +} + +func TestTracker_MeasureImpact(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + tr.AddSuggestion(evolve.SuggestionRecord{ + ID: "rec-4", + Status: "accepted", + CreatedAt: time.Now(), + }) + + scoresBefore := []float64{0.6, 0.8, 0.7} + scoresAfter := []float64{0.9, 0.85, 0.95} + got := tr.MeasureImpact("rec-4", scoresBefore, scoresAfter) + + if got == nil { + t.Fatal("expected non-nil SuggestionRecord") + } + if got.PreAvgScore == nil { + t.Fatal("expected PreAvgScore to be set") + } + expectedPre := (0.6 + 0.8 + 0.7) / 3 + if math.Abs(*got.PreAvgScore-expectedPre) > 1e-9 { + t.Errorf("expected PreAvgScore=%f, got %f", expectedPre, *got.PreAvgScore) + } + if got.PostAvgScore == nil { + t.Fatal("expected PostAvgScore to be set") + } + expectedPost := (0.9 + 0.85 + 0.95) / 3 + if math.Abs(*got.PostAvgScore-expectedPost) > 1e-9 { + t.Errorf("expected PostAvgScore=%f, got %f", expectedPost, *got.PostAvgScore) + } +} + +func TestTracker_MeasureImpact_EmptySlices(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + tr.AddSuggestion(evolve.SuggestionRecord{ + ID: "rec-5", + Status: "accepted", + CreatedAt: time.Now(), + }) + + got := tr.MeasureImpact("rec-5", nil, nil) + if got == nil { + t.Fatal("expected non-nil SuggestionRecord") + } + // Empty slices should result in nil scores (no data to average) + if got.PreAvgScore != nil { + t.Errorf("expected PreAvgScore=nil for empty slice, got %f", *got.PreAvgScore) + } + if got.PostAvgScore != nil { + t.Errorf("expected PostAvgScore=nil for empty slice, got %f", *got.PostAvgScore) + } +} + +func TestTracker_Pending(t *testing.T) { + t.Parallel() + + tr := evolve.NewTracker() + tr.AddSuggestion(evolve.SuggestionRecord{ID: "p1", Status: "pending", CreatedAt: time.Now()}) + tr.AddSuggestion(evolve.SuggestionRecord{ID: "p2", Status: "pending", CreatedAt: time.Now()}) + tr.AddSuggestion(evolve.SuggestionRecord{ID: "a1", Status: "accepted", CreatedAt: time.Now()}) + tr.AddSuggestion(evolve.SuggestionRecord{ID: "r1", Status: "rejected", CreatedAt: time.Now()}) + + pending := tr.Pending() + + if len(pending) != 2 { + t.Errorf("expected 2 pending records, got %d", len(pending)) + } + for _, rec := range pending { + if rec.Status != "pending" { + t.Errorf("expected Status=pending, got %q", rec.Status) + } + } +} diff --git a/cmd/entire/cli/evolve/trigger.go b/cmd/entire/cli/evolve/trigger.go new file mode 100644 index 000000000..d4e93c619 --- /dev/null +++ b/cmd/entire/cli/evolve/trigger.go @@ -0,0 +1,28 @@ +package evolve + +import ( + "time" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// ShouldTrigger returns true if the evolution loop should suggest running `entire improve`. +func ShouldTrigger(config settings.EvolveSettings, state State) bool { + if !config.Enabled { + return false + } + return state.SessionsSinceLastRun >= config.SessionThreshold +} + +// IncrementSessionCount updates the state after a session ends. +func IncrementSessionCount(state *State) { + state.SessionsSinceLastRun++ +} + +// RecordRun updates the state after an `entire improve` run completes. +func RecordRun(state *State, suggestionsGenerated int) { + state.SessionsSinceLastRun = 0 + state.TotalRuns++ + state.SuggestionsGenerated += suggestionsGenerated + state.LastRunAt = time.Now() +} diff --git a/cmd/entire/cli/evolve/trigger_test.go b/cmd/entire/cli/evolve/trigger_test.go new file mode 100644 index 000000000..9b8fb8939 --- /dev/null +++ b/cmd/entire/cli/evolve/trigger_test.go @@ -0,0 +1,90 @@ +package evolve_test + +import ( + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/evolve" + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +func TestShouldTrigger_ThresholdMet(t *testing.T) { + t.Parallel() + + config := settings.EvolveSettings{Enabled: true, SessionThreshold: 5} + state := evolve.State{SessionsSinceLastRun: 5} + + if !evolve.ShouldTrigger(config, state) { + t.Error("expected ShouldTrigger=true when sessions >= threshold") + } +} + +func TestShouldTrigger_ThresholdExceeded(t *testing.T) { + t.Parallel() + + config := settings.EvolveSettings{Enabled: true, SessionThreshold: 5} + state := evolve.State{SessionsSinceLastRun: 7} + + if !evolve.ShouldTrigger(config, state) { + t.Error("expected ShouldTrigger=true when sessions > threshold") + } +} + +func TestShouldTrigger_ThresholdNotMet(t *testing.T) { + t.Parallel() + + config := settings.EvolveSettings{Enabled: true, SessionThreshold: 5} + state := evolve.State{SessionsSinceLastRun: 3} + + if evolve.ShouldTrigger(config, state) { + t.Error("expected ShouldTrigger=false when sessions < threshold") + } +} + +func TestShouldTrigger_Disabled(t *testing.T) { + t.Parallel() + + config := settings.EvolveSettings{Enabled: false, SessionThreshold: 5} + state := evolve.State{SessionsSinceLastRun: 10} + + if evolve.ShouldTrigger(config, state) { + t.Error("expected ShouldTrigger=false when config is disabled") + } +} + +func TestIncrementSessionCount(t *testing.T) { + t.Parallel() + + state := evolve.State{SessionsSinceLastRun: 3} + evolve.IncrementSessionCount(&state) + + if state.SessionsSinceLastRun != 4 { + t.Errorf("expected SessionsSinceLastRun=4, got %d", state.SessionsSinceLastRun) + } +} + +func TestRecordRun(t *testing.T) { + t.Parallel() + + before := time.Now() + state := evolve.State{ + SessionsSinceLastRun: 7, + TotalRuns: 2, + SuggestionsGenerated: 5, + } + evolve.RecordRun(&state, 3) + after := time.Now() + + if state.SessionsSinceLastRun != 0 { + t.Errorf("expected SessionsSinceLastRun=0, got %d", state.SessionsSinceLastRun) + } + if state.TotalRuns != 3 { + t.Errorf("expected TotalRuns=3, got %d", state.TotalRuns) + } + if state.SuggestionsGenerated != 8 { + t.Errorf("expected SuggestionsGenerated=8, got %d", state.SuggestionsGenerated) + } + if state.LastRunAt.Before(before) || state.LastRunAt.After(after) { + t.Errorf("expected LastRunAt to be set to approximately now, got %v", state.LastRunAt) + } +} diff --git a/cmd/entire/cli/facets/facets.go b/cmd/entire/cli/facets/facets.go new file mode 100644 index 000000000..8e1875e07 --- /dev/null +++ b/cmd/entire/cli/facets/facets.go @@ -0,0 +1,100 @@ +package facets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/llmcli" +) + +// RepeatedInstruction captures an instruction the user had to restate. +type RepeatedInstruction struct { + Instruction string `json:"instruction"` + Evidence []string `json:"evidence,omitempty"` +} + +// MissingContextSignal captures a repo or workflow rule the agent likely lacked. +type MissingContextSignal struct { + Item string `json:"item"` + Evidence []string `json:"evidence,omitempty"` +} + +// FailureLoop captures a repeated failure/retry pattern within a session. +type FailureLoop struct { + Description string `json:"description"` + Count int `json:"count"` + Evidence []string `json:"evidence,omitempty"` +} + +// SkillSignal captures friction tied to a specific skill invocation. +type SkillSignal struct { + SkillName string `json:"skill_name"` + SkillPath string `json:"skill_path,omitempty"` + Friction []string `json:"friction,omitempty"` + MissingInstruction string `json:"missing_instruction,omitempty"` +} + +// SessionFacets is the structured output extracted from a single session. +type SessionFacets struct { + RepeatedUserInstructions []RepeatedInstruction `json:"repeated_user_instructions,omitempty"` + MissingContext []MissingContextSignal `json:"missing_context,omitempty"` + FailureLoops []FailureLoop `json:"failure_loops,omitempty"` + SkillSignals []SkillSignal `json:"skill_signals,omitempty"` + RepoGotchas []string `json:"repo_gotchas,omitempty"` + WorkflowGaps []string `json:"workflow_gaps,omitempty"` +} + +// Extractor extracts session facets using the shared LLM CLI runner. +type Extractor struct { + Runner *llmcli.Runner +} + +// Extract builds a facet prompt from transcript text and parses the JSON response. +func (e *Extractor) Extract(ctx context.Context, transcriptText string) (*SessionFacets, *llmcli.UsageInfo, error) { + if e.Runner == nil { + e.Runner = &llmcli.Runner{} + } + + raw, usage, err := e.Runner.Execute(ctx, BuildPrompt(transcriptText)) + if err != nil { + return nil, nil, fmt.Errorf("execute facets prompt: %w", err) + } + + var result SessionFacets + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return nil, nil, fmt.Errorf("parse facets JSON: %w", err) + } + + return &result, usage, nil +} + +// BuildPrompt constructs the extraction prompt. +func BuildPrompt(transcriptText string) string { + return fmt.Sprintf(`Analyze this development session transcript and extract structured facets. + + +%s + + +Return a JSON object with this exact structure: +{ + "repeated_user_instructions": [{"instruction": "instruction text", "evidence": ["short quote"]}], + "missing_context": [{"item": "missing rule or repo fact", "evidence": ["short quote"]}], + "failure_loops": [{"description": "repeat failure pattern", "count": 2, "evidence": ["short quote"]}], + "skill_signals": [{ + "skill_name": "skill identifier", + "skill_path": "optional/path/to/SKILL.md", + "friction": ["what went wrong after using the skill"], + "missing_instruction": "what the skill should add next time" + }], + "repo_gotchas": ["repo-specific gotcha"], + "workflow_gaps": ["workflow gap or missing step"] +} + +Guidelines: +- Focus on actionable repeated signals, not full summaries +- Prefer short evidence snippets +- Use empty arrays when a category is absent +- Return ONLY the JSON object`, transcriptText) +} diff --git a/cmd/entire/cli/facets/facets_test.go b/cmd/entire/cli/facets/facets_test.go new file mode 100644 index 000000000..afe9270c8 --- /dev/null +++ b/cmd/entire/cli/facets/facets_test.go @@ -0,0 +1,115 @@ +package facets_test + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/facets" + "github.com/entireio/cli/cmd/entire/cli/llmcli" +) + +func buildCLIResponse(result string) string { + b, err := json.Marshal(result) + if err != nil { + panic(fmt.Sprintf("failed to marshal result: %v", err)) + } + return fmt.Sprintf(`{"result":%s}`, string(b)) +} + +func TestExtractor_Extract_ReturnsStructuredFacets(t *testing.T) { + t.Parallel() + + inner := `{ + "repeated_user_instructions": [ + {"instruction": "Run golangci-lint before committing", "evidence": ["User repeated lint expectation"]} + ], + "missing_context": [ + {"item": "Repo requires gofmt to preserve nolint placement", "evidence": ["Agent missed repo-specific formatting rule"]} + ], + "failure_loops": [ + {"description": "Lint fix was applied and re-broken after fmt", "count": 2, "evidence": ["The same lint issue returned after formatting"]} + ], + "skill_signals": [ + { + "skill_name": "project:go-linting", + "skill_path": ".codex/skills/go-linting/SKILL.md", + "friction": ["Skill did not mention gofmt removing inline nolint comments"], + "missing_instruction": "Add a warning about trailing nolint comments on function signatures" + } + ], + "repo_gotchas": ["Go 1.26 gofmt strips trailing //nolint comments on signatures"], + "workflow_gaps": ["Agent should run fmt and lint as a paired verification step"] + }` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(inner) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + extractor := &facets.Extractor{Runner: runner} + + got, _, err := extractor.Extract(context.Background(), "session transcript") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got.RepeatedUserInstructions) != 1 { + t.Fatalf("expected 1 repeated instruction, got %d", len(got.RepeatedUserInstructions)) + } + if got.RepeatedUserInstructions[0].Instruction != "Run golangci-lint before committing" { + t.Fatalf("unexpected instruction: %q", got.RepeatedUserInstructions[0].Instruction) + } + if len(got.MissingContext) != 1 || got.MissingContext[0].Item == "" { + t.Fatalf("expected missing context signal, got %+v", got.MissingContext) + } + if len(got.FailureLoops) != 1 || got.FailureLoops[0].Count != 2 { + t.Fatalf("expected one failure loop with count 2, got %+v", got.FailureLoops) + } + if len(got.SkillSignals) != 1 { + t.Fatalf("expected 1 skill signal, got %d", len(got.SkillSignals)) + } + if got.SkillSignals[0].SkillName != "project:go-linting" { + t.Fatalf("unexpected skill name: %q", got.SkillSignals[0].SkillName) + } +} + +func TestExtractor_Extract_InvalidJSON(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse("not valid json") + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + extractor := &facets.Extractor{Runner: runner} + + if _, _, err := extractor.Extract(context.Background(), "session transcript"); err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestBuildPrompt_IncludesFacetSchema(t *testing.T) { + t.Parallel() + + prompt := facets.BuildPrompt("Skill: project:go-linting") + + for _, want := range []string{ + "", + "repeated_user_instructions", + "missing_context", + "failure_loops", + "skill_signals", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q: %s", want, prompt) + } + } +} diff --git a/cmd/entire/cli/improve/analyzer.go b/cmd/entire/cli/improve/analyzer.go new file mode 100644 index 000000000..99f6efbcf --- /dev/null +++ b/cmd/entire/cli/improve/analyzer.go @@ -0,0 +1,303 @@ +package improve + +import ( + "strings" + + "github.com/entireio/cli/cmd/entire/cli/facets" +) + +// maxFrictionExamples caps the number of raw examples stored per friction theme +// to prevent unbounded growth in LLM prompts. +const maxFrictionExamples = 10 + +// SessionSummaryData pairs a session identifier with its friction and learnings. +// This is populated from the insightsdb cache. +type SessionSummaryData struct { + CheckpointID string + Friction []string + Learnings []LearningEntry + OpenItems []string + Facets facets.SessionFacets +} + +// LearningEntry represents a single learning from a session. +type LearningEntry struct { + Scope string // "repo", "workflow", "code" + Finding string + Path string +} + +// frictionThemeKeywords maps theme names to their detection keywords. +// Order matters: first match wins. +var frictionThemeKeywords = []struct { + theme string + keywords []string +}{ + {"lint", []string{"lint", "golangci", "linter"}}, + {"import", []string{"import"}}, + {"compile", []string{"compile", "build error", "compilation"}}, + {"format", []string{"format", "fmt", "gofmt"}}, + {"test", []string{"test", "testing", "test failure"}}, + {"type", []string{"type assertion", "type error", "type mismatch", "type check"}}, + {"permission", []string{"permission", "denied", "unauthorized"}}, + {"timeout", []string{"timeout", "timed out"}}, + {"retry", []string{"retry", "retrying"}}, + {"api", []string{"api", "500", "http error", "rate limit"}}, + {"ci", []string{"ci failure", "ci ", "pipeline"}}, + {"conflict", []string{"conflict", "merge", "concurrent edit", "rebase"}}, + {"review", []string{"review", "pr review", "copilot review"}}, + {"scope", []string{"scope", "out of scope", "unrelated"}}, +} + +// classifyFriction returns the theme keyword for a friction string, +// or "other" if no known keyword matches. +func classifyFriction(text string) string { + lower := strings.ToLower(text) + for _, entry := range frictionThemeKeywords { + for _, kw := range entry.keywords { + if strings.Contains(lower, kw) { + return entry.theme + } + } + } + return "other" +} + +// frictionAccumulator accumulates friction examples and affected sessions per theme. +type frictionAccumulator struct { + count int + examples []string + sessions map[string]struct{} // deduplicated session IDs +} + +type recurringSignalAccumulator struct { + count int + evidence []string + sessions map[string]struct{} +} + +type skillOpportunityAccumulator struct { + skillPath string + count int + friction []string + missingInstruction string + sessions map[string]struct{} +} + +// AnalyzePatterns extracts recurring patterns from session summary data. +// This is the "index phase" — it works on data already in memory from SQLite. +func AnalyzePatterns(summaries []SessionSummaryData) PatternAnalysis { + if len(summaries) == 0 { + return PatternAnalysis{} + } + + // Accumulate friction by theme + byTheme := make(map[string]*frictionAccumulator) + instructionSignals := make(map[string]*recurringSignalAccumulator) + missingContextSignals := make(map[string]*recurringSignalAccumulator) + failureLoopSignals := make(map[string]*recurringSignalAccumulator) + skillSignals := make(map[string]*skillOpportunityAccumulator) + + for _, s := range summaries { + for _, f := range s.Friction { + theme := classifyFriction(f) + acc, ok := byTheme[theme] + if !ok { + acc = &frictionAccumulator{ + sessions: make(map[string]struct{}), + } + byTheme[theme] = acc + } + acc.count++ + if len(acc.examples) < maxFrictionExamples { + acc.examples = append(acc.examples, f) + } + if s.CheckpointID != "" { + acc.sessions[s.CheckpointID] = struct{}{} + } + } + + for _, instruction := range s.Facets.RepeatedUserInstructions { + accumulateRecurringSignal(instructionSignals, instruction.Instruction, instruction.Evidence, s.CheckpointID) + } + for _, signal := range s.Facets.MissingContext { + accumulateRecurringSignal(missingContextSignals, signal.Item, signal.Evidence, s.CheckpointID) + } + for _, loop := range s.Facets.FailureLoops { + key := loop.Description + acc := failureLoopSignals[key] + if acc == nil { + acc = &recurringSignalAccumulator{sessions: make(map[string]struct{})} + failureLoopSignals[key] = acc + } + acc.count += max(loop.Count, 1) + accumulateEvidence(acc, loop.Evidence) + if s.CheckpointID != "" { + acc.sessions[s.CheckpointID] = struct{}{} + } + } + for _, signal := range s.Facets.SkillSignals { + key := signal.SkillName + if key == "" { + continue + } + acc := skillSignals[key] + if acc == nil { + acc = &skillOpportunityAccumulator{sessions: make(map[string]struct{})} + skillSignals[key] = acc + } + acc.count++ + if acc.skillPath == "" { + acc.skillPath = signal.SkillPath + } + if acc.missingInstruction == "" { + acc.missingInstruction = signal.MissingInstruction + } + acc.friction = appendLimited(acc.friction, signal.Friction, maxFrictionExamples) + if s.CheckpointID != "" { + acc.sessions[s.CheckpointID] = struct{}{} + } + } + } + + // Build repeated friction list (threshold: 2+ occurrences) + var repeated []FrictionPattern + for theme, acc := range byTheme { + if acc.count < 2 { + continue + } + sessions := make([]string, 0, len(acc.sessions)) + for id := range acc.sessions { + sessions = append(sessions, id) + } + repeated = append(repeated, FrictionPattern{ + Theme: theme, + Count: acc.count, + Examples: acc.examples, + AffectedSessions: sessions, + }) + } + + repeatedInstructions := buildRecurringSignals(instructionSignals) + missingSignals := buildRecurringSignals(missingContextSignals) + failureLoops := buildRecurringSignals(failureLoopSignals) + skillOpportunities := buildSkillOpportunities(skillSignals) + + // Deduplicate learnings by scope + repoSeen := make(map[string]struct{}) + workflowSeen := make(map[string]struct{}) + var repoLearnings, workflowLearnings []string + + for _, s := range summaries { + for _, l := range s.Learnings { + switch l.Scope { + case "repo": + if _, seen := repoSeen[l.Finding]; !seen { + repoSeen[l.Finding] = struct{}{} + repoLearnings = append(repoLearnings, l.Finding) + } + case "workflow": + if _, seen := workflowSeen[l.Finding]; !seen { + workflowSeen[l.Finding] = struct{}{} + workflowLearnings = append(workflowLearnings, l.Finding) + } + } + } + } + + // Deduplicate open items + openSeen := make(map[string]struct{}) + var openItems []string + for _, s := range summaries { + for _, item := range s.OpenItems { + if _, seen := openSeen[item]; !seen { + openSeen[item] = struct{}{} + openItems = append(openItems, item) + } + } + } + + return PatternAnalysis{ + RepeatedFriction: repeated, + RepeatedInstructions: repeatedInstructions, + MissingContextSignals: missingSignals, + FailureLoops: failureLoops, + SkillOpportunities: skillOpportunities, + RepoLearnings: repoLearnings, + WorkflowLearnings: workflowLearnings, + OpenItems: openItems, + SessionCount: len(summaries), + } +} + +func accumulateRecurringSignal(target map[string]*recurringSignalAccumulator, value string, evidence []string, checkpointID string) { + if value == "" { + return + } + acc := target[value] + if acc == nil { + acc = &recurringSignalAccumulator{sessions: make(map[string]struct{})} + target[value] = acc + } + acc.count++ + accumulateEvidence(acc, evidence) + if checkpointID != "" { + acc.sessions[checkpointID] = struct{}{} + } +} + +func accumulateEvidence(acc *recurringSignalAccumulator, evidence []string) { + acc.evidence = appendLimited(acc.evidence, evidence, maxFrictionExamples) +} + +func appendLimited(dst, src []string, limit int) []string { + for _, item := range src { + if item == "" || len(dst) >= limit { + break + } + dst = append(dst, item) + } + return dst +} + +func buildRecurringSignals(byValue map[string]*recurringSignalAccumulator) []RecurringSignal { + signals := make([]RecurringSignal, 0, len(byValue)) + for value, acc := range byValue { + if acc.count < 2 { + continue + } + signals = append(signals, RecurringSignal{ + Value: value, + Count: acc.count, + Evidence: acc.evidence, + AffectedSessions: sessionIDs(acc.sessions), + }) + } + return signals +} + +func buildSkillOpportunities(bySkill map[string]*skillOpportunityAccumulator) []SkillOpportunity { + opportunities := make([]SkillOpportunity, 0, len(bySkill)) + for skillName, acc := range bySkill { + if acc.count < 2 { + continue + } + opportunities = append(opportunities, SkillOpportunity{ + SkillName: skillName, + SkillPath: acc.skillPath, + Count: acc.count, + Friction: acc.friction, + MissingInstruction: acc.missingInstruction, + AffectedSessions: sessionIDs(acc.sessions), + }) + } + return opportunities +} + +func sessionIDs(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for id := range values { + out = append(out, id) + } + return out +} diff --git a/cmd/entire/cli/improve/analyzer_test.go b/cmd/entire/cli/improve/analyzer_test.go new file mode 100644 index 000000000..ae8a1f6d9 --- /dev/null +++ b/cmd/entire/cli/improve/analyzer_test.go @@ -0,0 +1,359 @@ +package improve_test + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/facets" + "github.com/entireio/cli/cmd/entire/cli/improve" +) + +func TestAnalyzePatterns_EmptySummaries(t *testing.T) { + t.Parallel() + + result := improve.AnalyzePatterns(nil) + + if result.SessionCount != 0 { + t.Errorf("expected SessionCount=0, got %d", result.SessionCount) + } + if len(result.RepeatedFriction) != 0 { + t.Errorf("expected no repeated friction, got %d", len(result.RepeatedFriction)) + } + if len(result.RepoLearnings) != 0 { + t.Errorf("expected no repo learnings, got %d", len(result.RepoLearnings)) + } +} + +func TestAnalyzePatterns_SingleSession(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "abc123", + Friction: []string{"Lint errors not caught by agent"}, + Learnings: []improve.LearningEntry{ + {Scope: "repo", Finding: "Uses golangci-lint"}, + }, + }, + } + + result := improve.AnalyzePatterns(summaries) + + if result.SessionCount != 1 { + t.Errorf("expected SessionCount=1, got %d", result.SessionCount) + } + // Single occurrence should NOT be repeated friction + if len(result.RepeatedFriction) != 0 { + t.Errorf("expected no repeated friction from single session, got %d", len(result.RepeatedFriction)) + } + if len(result.RepoLearnings) != 1 { + t.Errorf("expected 1 repo learning, got %d", len(result.RepoLearnings)) + } +} + +func TestAnalyzePatterns_RepeatedFrictionGroupsByTheme(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "aaa111", + Friction: []string{"Lint errors not caught", "Had to fix golangci-lint errors manually"}, + }, + { + CheckpointID: "bbb222", + Friction: []string{"lint check failed again"}, + }, + } + + result := improve.AnalyzePatterns(summaries) + + if result.SessionCount != 2 { + t.Errorf("expected SessionCount=2, got %d", result.SessionCount) + } + + // All three should group under "lint" theme + if len(result.RepeatedFriction) == 0 { + t.Fatal("expected at least one repeated friction pattern") + } + + var lintPattern *improve.FrictionPattern + for i := range result.RepeatedFriction { + if result.RepeatedFriction[i].Theme == "lint" { + lintPattern = &result.RepeatedFriction[i] + break + } + } + if lintPattern == nil { + t.Fatal("expected lint theme in repeated friction") + } + if lintPattern.Count != 3 { + t.Errorf("expected lint count=3, got %d", lintPattern.Count) + } + if len(lintPattern.Examples) != 3 { + t.Errorf("expected 3 examples, got %d", len(lintPattern.Examples)) + } + if len(lintPattern.AffectedSessions) != 2 { + t.Errorf("expected 2 affected sessions, got %d", len(lintPattern.AffectedSessions)) + } +} + +func TestAnalyzePatterns_FrictionThresholdIsTwo(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "s1", + Friction: []string{"test runner timeout once"}, + }, + { + CheckpointID: "s2", + Friction: []string{"test runner timeout again"}, + }, + } + + result := improve.AnalyzePatterns(summaries) + + // "test" theme appears 2 times — must show up as repeated + found := false + for _, p := range result.RepeatedFriction { + if p.Theme == "test" { + found = true + if p.Count < 2 { + t.Errorf("expected count >= 2 for test theme, got %d", p.Count) + } + } + } + if !found { + t.Error("expected 'test' theme with 2 occurrences to appear in repeated friction") + } +} + +func TestAnalyzePatterns_DeduplicatesLearnings(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "s1", + Learnings: []improve.LearningEntry{ + {Scope: "repo", Finding: "Uses golangci-lint"}, + {Scope: "workflow", Finding: "Run tests before committing"}, + }, + }, + { + CheckpointID: "s2", + Learnings: []improve.LearningEntry{ + {Scope: "repo", Finding: "Uses golangci-lint"}, // duplicate + {Scope: "workflow", Finding: "Push to feature branch"}, + }, + }, + } + + result := improve.AnalyzePatterns(summaries) + + // "Uses golangci-lint" should appear only once + repoCount := 0 + for _, l := range result.RepoLearnings { + if l == "Uses golangci-lint" { + repoCount++ + } + } + if repoCount != 1 { + t.Errorf("expected deduplicated repo learning count=1, got %d", repoCount) + } + + if len(result.WorkflowLearnings) != 2 { + t.Errorf("expected 2 workflow learnings, got %d", len(result.WorkflowLearnings)) + } +} + +func TestAnalyzePatterns_KnownThemes(t *testing.T) { + t.Parallel() + + // Verify each known theme keyword is recognized + tests := []struct { + frictionText string + expectedTheme string + }{ + {"import cycle detected", "import"}, + {"compile error in main.go", "compile"}, + {"format check failed", "format"}, + {"permission denied reading file", "permission"}, + {"request timeout after 30s", "timeout"}, + {"retry attempt 3 failed", "retry"}, + {"type assertion failed", "type"}, + } + + for _, tt := range tests { + t.Run(tt.expectedTheme, func(t *testing.T) { + t.Parallel() + + // Need 2+ to trigger repeated friction + summaries := []improve.SessionSummaryData{ + {CheckpointID: "s1", Friction: []string{tt.frictionText}}, + {CheckpointID: "s2", Friction: []string{tt.frictionText + " again"}}, + } + + result := improve.AnalyzePatterns(summaries) + + found := false + for _, p := range result.RepeatedFriction { + if p.Theme == tt.expectedTheme { + found = true + break + } + } + if !found { + t.Errorf("expected theme %q for friction %q", tt.expectedTheme, tt.frictionText) + } + }) + } +} + +func TestAnalyzePatterns_OpenItems(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "s1", + OpenItems: []string{"TODO: add authentication", "Fix flaky test"}, + }, + { + CheckpointID: "s2", + OpenItems: []string{"TODO: add authentication"}, // duplicate + }, + } + + result := improve.AnalyzePatterns(summaries) + + // Deduplication: "TODO: add authentication" appears once, "Fix flaky test" once + if len(result.OpenItems) != 2 { + t.Errorf("expected 2 deduplicated open items, got %d: %v", len(result.OpenItems), result.OpenItems) + } +} + +func TestAnalyzePatterns_MultipleFrictionThemes(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "s1", + Friction: []string{"lint error", "test failure"}, + }, + { + CheckpointID: "s2", + Friction: []string{"lint warning", "import error"}, + }, + } + + result := improve.AnalyzePatterns(summaries) + + themeSet := make(map[string]bool) + for _, p := range result.RepeatedFriction { + themeSet[p.Theme] = true + } + + // "lint" appears twice (threshold=2), "test" and "import" appear once + if !themeSet["lint"] { + t.Error("expected lint in repeated friction themes") + } + if themeSet["test"] { + t.Error("test appears once only, should not be repeated") + } + if themeSet["import"] { + t.Error("import appears once only, should not be repeated") + } +} + +func TestAnalyzePatterns_UsesStructuredFacetsForRecurringSignals(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "s1", + Facets: facets.SessionFacets{ + RepeatedUserInstructions: []facets.RepeatedInstruction{ + {Instruction: "Run golangci-lint before committing", Evidence: []string{"User repeated lint expectation"}}, + }, + MissingContext: []facets.MissingContextSignal{ + {Item: "Repo requires gofmt after edits", Evidence: []string{"Agent skipped fmt"}}, + }, + FailureLoops: []facets.FailureLoop{ + {Description: "Lint issue returned after fmt", Count: 2, Evidence: []string{"reappeared after formatting"}}, + }, + }, + }, + { + CheckpointID: "s2", + Facets: facets.SessionFacets{ + RepeatedUserInstructions: []facets.RepeatedInstruction{ + {Instruction: "Run golangci-lint before committing", Evidence: []string{"Lint request repeated"}}, + }, + MissingContext: []facets.MissingContextSignal{ + {Item: "Repo requires gofmt after edits", Evidence: []string{"Formatting missed again"}}, + }, + FailureLoops: []facets.FailureLoop{ + {Description: "Lint issue returned after fmt", Count: 2, Evidence: []string{"same loop happened again"}}, + }, + }, + }, + } + + result := improve.AnalyzePatterns(summaries) + + if len(result.RepeatedInstructions) != 1 { + t.Fatalf("expected 1 repeated instruction, got %d", len(result.RepeatedInstructions)) + } + if result.RepeatedInstructions[0].Value != "Run golangci-lint before committing" { + t.Fatalf("unexpected instruction value: %q", result.RepeatedInstructions[0].Value) + } + if len(result.MissingContextSignals) != 1 { + t.Fatalf("expected 1 missing context signal, got %d", len(result.MissingContextSignals)) + } + if len(result.FailureLoops) != 1 { + t.Fatalf("expected 1 failure loop, got %d", len(result.FailureLoops)) + } +} + +func TestAnalyzePatterns_CreatesSkillOpportunities(t *testing.T) { + t.Parallel() + + summaries := []improve.SessionSummaryData{ + { + CheckpointID: "s1", + Facets: facets.SessionFacets{ + SkillSignals: []facets.SkillSignal{ + { + SkillName: "project:go-linting", + SkillPath: ".codex/skills/go-linting/SKILL.md", + Friction: []string{"Skill did not warn about gofmt removing inline nolint comments"}, + MissingInstruction: "Warn about trailing nolint comments on function signatures", + }, + }, + }, + }, + { + CheckpointID: "s2", + Facets: facets.SessionFacets{ + SkillSignals: []facets.SkillSignal{ + { + SkillName: "project:go-linting", + SkillPath: ".codex/skills/go-linting/SKILL.md", + Friction: []string{"Same lint issue came back after following the skill"}, + MissingInstruction: "Warn about trailing nolint comments on function signatures", + }, + }, + }, + }, + } + + result := improve.AnalyzePatterns(summaries) + + if len(result.SkillOpportunities) != 1 { + t.Fatalf("expected 1 skill opportunity, got %d", len(result.SkillOpportunities)) + } + if result.SkillOpportunities[0].SkillName != "project:go-linting" { + t.Fatalf("unexpected skill opportunity: %+v", result.SkillOpportunities[0]) + } + if result.SkillOpportunities[0].Count != 2 { + t.Fatalf("expected skill opportunity count 2, got %d", result.SkillOpportunities[0].Count) + } +} diff --git a/cmd/entire/cli/improve/context_files.go b/cmd/entire/cli/improve/context_files.go new file mode 100644 index 000000000..e79bb09a9 --- /dev/null +++ b/cmd/entire/cli/improve/context_files.go @@ -0,0 +1,47 @@ +package improve + +import ( + "errors" + "os" + "path/filepath" +) + +// knownContextFiles lists all known context file types and their relative paths. +var knownContextFiles = []struct { + fileType ContextFileType + relPath string +}{ + {ContextFileCLAUDEMD, "CLAUDE.md"}, + {ContextFileAGENTSMD, "AGENTS.md"}, + {ContextFileCursorRules, ".cursorrules"}, + {ContextFileGemini, ".gemini/settings.json"}, +} + +// DetectContextFiles scans a project root for known context files. +// Returns entries for all known types, with Exists=false for missing files. +func DetectContextFiles(root string) []ContextFile { + results := make([]ContextFile, 0, len(knownContextFiles)) + + for _, known := range knownContextFiles { + absPath := filepath.Join(root, known.relPath) + cf := ContextFile{ + Type: known.fileType, + Path: absPath, + } + + data, err := os.ReadFile(absPath) //nolint:gosec // path is constructed from trusted root + known relative paths + if err == nil { + cf.Exists = true + cf.Content = string(data) + cf.SizeBytes = len(data) + } else if !errors.Is(err, os.ErrNotExist) { + // File exists but cannot be read — mark as existing without content. + // Permissions errors, symlink issues, etc. fall here. + cf.Exists = true + } + + results = append(results, cf) + } + + return results +} diff --git a/cmd/entire/cli/improve/context_files_test.go b/cmd/entire/cli/improve/context_files_test.go new file mode 100644 index 000000000..0fafc703b --- /dev/null +++ b/cmd/entire/cli/improve/context_files_test.go @@ -0,0 +1,182 @@ +package improve_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/improve" +) + +func TestDetectContextFiles_AllPresent(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + // Create all known context files + writeFile(t, root, "CLAUDE.md", "# Claude instructions\nDo the thing.") + writeFile(t, root, "AGENTS.md", "# Agents instructions\nBe helpful.") + writeFile(t, root, ".cursorrules", "cursor rules content") + + geminiDir := filepath.Join(root, ".gemini") + if err := os.MkdirAll(geminiDir, 0o755); err != nil { + t.Fatalf("failed to create .gemini dir: %v", err) + } + writeFile(t, root, ".gemini/settings.json", `{"model":"gemini-pro"}`) + + files := improve.DetectContextFiles(root) + + if len(files) != 4 { + t.Fatalf("expected 4 files, got %d", len(files)) + } + + byType := make(map[improve.ContextFileType]improve.ContextFile) + for _, f := range files { + byType[f.Type] = f + } + + // Verify CLAUDE.md + cf := byType[improve.ContextFileCLAUDEMD] + if !cf.Exists { + t.Error("CLAUDE.md: expected Exists=true") + } + if cf.Content != "# Claude instructions\nDo the thing." { + t.Errorf("CLAUDE.md: unexpected content %q", cf.Content) + } + if cf.SizeBytes != len("# Claude instructions\nDo the thing.") { + t.Errorf("CLAUDE.md: unexpected size %d", cf.SizeBytes) + } + if cf.Path != filepath.Join(root, "CLAUDE.md") { + t.Errorf("CLAUDE.md: unexpected path %q", cf.Path) + } + + // Verify AGENTS.md + af := byType[improve.ContextFileAGENTSMD] + if !af.Exists { + t.Error("AGENTS.md: expected Exists=true") + } + if af.Content != "# Agents instructions\nBe helpful." { + t.Errorf("AGENTS.md: unexpected content %q", af.Content) + } + + // Verify .cursorrules + cr := byType[improve.ContextFileCursorRules] + if !cr.Exists { + t.Error(".cursorrules: expected Exists=true") + } + if cr.Content != "cursor rules content" { + t.Errorf(".cursorrules: unexpected content %q", cr.Content) + } + + // Verify .gemini/settings.json + gs := byType[improve.ContextFileGemini] + if !gs.Exists { + t.Error(".gemini/settings.json: expected Exists=true") + } + if gs.Content != `{"model":"gemini-pro"}` { + t.Errorf(".gemini/settings.json: unexpected content %q", gs.Content) + } +} + +func TestDetectContextFiles_NonePresent(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + files := improve.DetectContextFiles(root) + + if len(files) != 4 { + t.Fatalf("expected 4 entries (all missing), got %d", len(files)) + } + + for _, f := range files { + if f.Exists { + t.Errorf("%s: expected Exists=false for missing file", f.Type) + } + if f.Content != "" { + t.Errorf("%s: expected empty Content for missing file, got %q", f.Type, f.Content) + } + if f.SizeBytes != 0 { + t.Errorf("%s: expected SizeBytes=0 for missing file, got %d", f.Type, f.SizeBytes) + } + } +} + +func TestDetectContextFiles_PartialPresent(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFile(t, root, "CLAUDE.md", "only claude") + + files := improve.DetectContextFiles(root) + + if len(files) != 4 { + t.Fatalf("expected 4 entries, got %d", len(files)) + } + + byType := make(map[improve.ContextFileType]improve.ContextFile) + for _, f := range files { + byType[f.Type] = f + } + + if !byType[improve.ContextFileCLAUDEMD].Exists { + t.Error("CLAUDE.md: should exist") + } + if byType[improve.ContextFileAGENTSMD].Exists { + t.Error("AGENTS.md: should not exist") + } + if byType[improve.ContextFileCursorRules].Exists { + t.Error(".cursorrules: should not exist") + } + if byType[improve.ContextFileGemini].Exists { + t.Error(".gemini/settings.json: should not exist") + } +} + +func TestDetectContextFiles_PathsAreAbsolute(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + files := improve.DetectContextFiles(root) + + for _, f := range files { + if !filepath.IsAbs(f.Path) { + t.Errorf("%s: expected absolute path, got %q", f.Type, f.Path) + } + } +} + +func TestDetectContextFiles_EmptyFile(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFile(t, root, "CLAUDE.md", "") + + files := improve.DetectContextFiles(root) + + byType := make(map[improve.ContextFileType]improve.ContextFile) + for _, f := range files { + byType[f.Type] = f + } + + cf := byType[improve.ContextFileCLAUDEMD] + if !cf.Exists { + t.Error("CLAUDE.md: empty file should still exist") + } + if cf.SizeBytes != 0 { + t.Errorf("CLAUDE.md: expected SizeBytes=0, got %d", cf.SizeBytes) + } + if cf.Content != "" { + t.Errorf("CLAUDE.md: expected empty Content, got %q", cf.Content) + } +} + +// writeFile is a helper that creates a file with given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } +} diff --git a/cmd/entire/cli/improve/generator.go b/cmd/entire/cli/improve/generator.go new file mode 100644 index 000000000..517b17462 --- /dev/null +++ b/cmd/entire/cli/improve/generator.go @@ -0,0 +1,248 @@ +package improve + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/llmcli" +) + +// Generator generates improvement suggestions using the Claude CLI. +type Generator struct { + // Runner is the shared Claude CLI execution runner. + Runner *llmcli.Runner +} + +// suggestionResponse is the expected JSON structure returned by the Claude CLI. +type suggestionResponse struct { + Suggestions []suggestionJSON `json:"suggestions"` +} + +// suggestionJSON is the per-suggestion JSON structure in the Claude CLI response. +type suggestionJSON struct { + TargetKind string `json:"target_kind"` + FileType ContextFileType `json:"file_type"` + FilePath string `json:"file_path"` + SkillName string `json:"skill_name"` + Category string `json:"category"` + Title string `json:"title"` + Description string `json:"description"` + Evidence []string `json:"evidence"` + Priority string `json:"priority"` + CopyablePrompt string `json:"copyable_prompt"` + SuggestedInstruction string `json:"suggested_instruction"` + Diff string `json:"diff"` +} + +// GenerateResult holds the suggestions and usage info from a Generate call. +type GenerateResult struct { + Suggestions []Suggestion + Usage *llmcli.UsageInfo +} + +// Generate produces context file improvement suggestions. +// analysis contains friction patterns and transcript excerpts. +// contextFiles contains the current context file contents. +func (g *Generator) Generate(ctx context.Context, analysis PatternAnalysis, contextFiles []ContextFile) (*GenerateResult, error) { + prompt := buildPrompt(analysis, contextFiles) + + if g.Runner == nil { + g.Runner = &llmcli.Runner{} + } + + raw, usage, err := g.Runner.Execute(ctx, prompt) + if err != nil { + return nil, fmt.Errorf("failed to execute improvement prompt: %w", err) + } + + var resp suggestionResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return nil, fmt.Errorf("failed to parse improvement suggestions: %w", err) + } + + now := time.Now() + suggestions := make([]Suggestion, 0, len(resp.Suggestions)) + for i, s := range resp.Suggestions { + suggestions = append(suggestions, Suggestion{ + ID: fmt.Sprintf("sug-%d-%d", now.Unix(), i), + TargetKind: s.TargetKind, + FileType: s.FileType, + FilePath: s.FilePath, + SkillName: s.SkillName, + Category: s.Category, + Title: s.Title, + Description: s.Description, + Evidence: s.Evidence, + Priority: s.Priority, + CopyablePrompt: s.CopyablePrompt, + SuggestedInstruction: s.SuggestedInstruction, + Diff: s.Diff, + CreatedAt: now, + Status: "pending", + }) + } + + return &GenerateResult{Suggestions: suggestions, Usage: usage}, nil +} + +// buildPrompt constructs the prompt for the Claude CLI. +// All untrusted content (friction text, learnings, context file content) is wrapped +// in XML tags to prevent prompt injection. +func buildPrompt(analysis PatternAnalysis, contextFiles []ContextFile) string { + var sb strings.Builder + + sb.WriteString(`Analyze recurring patterns from recent AI coding sessions and suggest +improvements to prompts, repo instructions, and project skills. + +`) + + // Repeated friction section + sb.WriteString("\n") + if len(analysis.RepeatedFriction) == 0 { + sb.WriteString("(no repeated friction patterns found)\n") + } else { + for _, p := range analysis.RepeatedFriction { + fmt.Fprintf(&sb, "Theme: %s issues (occurred %d times)\n", p.Theme, p.Count) + for _, ex := range p.Examples { + fmt.Fprintf(&sb, " - %q\n", ex) + } + if p.TranscriptExcerpt != "" { + fmt.Fprintf(&sb, " Excerpt: %q\n", p.TranscriptExcerpt) + } + } + } + sb.WriteString("\n\n") + + sb.WriteString("\n") + if len(analysis.RepeatedInstructions) == 0 { + sb.WriteString("(no repeated user instructions found)\n") + } else { + for _, signal := range analysis.RepeatedInstructions { + fmt.Fprintf(&sb, "Instruction: %s (occurred %d times)\n", signal.Value, signal.Count) + for _, evidence := range signal.Evidence { + fmt.Fprintf(&sb, " - %q\n", evidence) + } + } + } + sb.WriteString("\n\n") + + sb.WriteString("\n") + if len(analysis.MissingContextSignals) == 0 { + sb.WriteString("(no missing context patterns found)\n") + } else { + for _, signal := range analysis.MissingContextSignals { + fmt.Fprintf(&sb, "Missing: %s (occurred %d times)\n", signal.Value, signal.Count) + for _, evidence := range signal.Evidence { + fmt.Fprintf(&sb, " - %q\n", evidence) + } + } + } + sb.WriteString("\n\n") + + sb.WriteString("\n") + if len(analysis.FailureLoops) == 0 { + sb.WriteString("(no failure loops found)\n") + } else { + for _, signal := range analysis.FailureLoops { + fmt.Fprintf(&sb, "Loop: %s (score %d)\n", signal.Value, signal.Count) + for _, evidence := range signal.Evidence { + fmt.Fprintf(&sb, " - %q\n", evidence) + } + } + } + sb.WriteString("\n\n") + + sb.WriteString("\n") + if len(analysis.SkillOpportunities) == 0 { + sb.WriteString("(no skill-related opportunities found)\n") + } else { + for _, skill := range analysis.SkillOpportunities { + fmt.Fprintf(&sb, "Skill: %s\n", skill.SkillName) + if skill.SkillPath != "" { + fmt.Fprintf(&sb, " Path: %s\n", skill.SkillPath) + } + fmt.Fprintf(&sb, " Count: %d\n", skill.Count) + if skill.MissingInstruction != "" { + fmt.Fprintf(&sb, " Missing instruction: %s\n", skill.MissingInstruction) + } + for _, friction := range skill.Friction { + fmt.Fprintf(&sb, " Friction: %q\n", friction) + } + } + } + sb.WriteString("\n\n") + + // Transcript excerpts section + sb.WriteString("\n") + hasExcerpts := false + for _, p := range analysis.RepeatedFriction { + if p.TranscriptExcerpt != "" { + hasExcerpts = true + break + } + } + if !hasExcerpts { + sb.WriteString("(transcript excerpts go here when available — may be empty for dry-run)\n") + } + sb.WriteString("\n\n") + + // Learnings section + sb.WriteString("\n") + for _, l := range analysis.RepoLearnings { + fmt.Fprintf(&sb, "Repo: %s\n", l) + } + for _, l := range analysis.WorkflowLearnings { + fmt.Fprintf(&sb, "Workflow: %s\n", l) + } + if len(analysis.RepoLearnings) == 0 && len(analysis.WorkflowLearnings) == 0 { + sb.WriteString("(no learnings recorded)\n") + } + sb.WriteString("\n\n") + + // Current context files section + sb.WriteString("\n") + for _, cf := range contextFiles { + if cf.Exists { + fmt.Fprintf(&sb, "--- %s (%d bytes) ---\n", cf.Type, cf.SizeBytes) + sb.WriteString(cf.Content) + sb.WriteString("\n--- end ---\n\n") + } else { + fmt.Fprintf(&sb, "--- %s (does not exist) ---\n\n", cf.Type) + } + } + if len(contextFiles) == 0 { + sb.WriteString("(no context files provided)\n") + } + sb.WriteString("\n\n") + + sb.WriteString(`Return a JSON object with this structure: +{ + "suggestions": [{ + "target_kind": "prompt_recommendation|skill_recommendation|workflow_recommendation", + "file_type": "CLAUDE.md", + "file_path": "/abs/path/to/file", + "skill_name": "optional skill name", + "category": "missing_context|skill_gap|workflow_gap|fix_friction", + "title": "Short title", + "description": "Why this helps", + "evidence": ["friction quote 1"], + "priority": "high|medium|low", + "copyable_prompt": "Optional prompt text the developer can reuse", + "suggested_instruction": "Optional instruction block or skill change", + "diff": "Optional unified diff" + }] +} + +Guidelines: +- Focus on repeated structured signals, not one-off complaints +- Prefer prompt recommendations and skill updates over raw diagnostics +- Include concrete wording the developer can copy into prompts or skill files +- Unified diffs are optional; only include them when clearly helpful +- Priority: high for 3+ occurrences, medium for 2, low for learnings +`) + + return sb.String() +} diff --git a/cmd/entire/cli/improve/generator_test.go b/cmd/entire/cli/improve/generator_test.go new file mode 100644 index 000000000..d2d5019c7 --- /dev/null +++ b/cmd/entire/cli/improve/generator_test.go @@ -0,0 +1,409 @@ +package improve_test + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/improve" + "github.com/entireio/cli/cmd/entire/cli/llmcli" +) + +// emptySuggestions is the JSON response for a call that returns no suggestions. +const emptySuggestions = `{"suggestions": []}` + +// buildCLIResponse wraps a result string in the Claude CLI JSON envelope. +func buildCLIResponse(result string) string { + b, err := json.Marshal(result) + if err != nil { + panic(fmt.Sprintf("failed to marshal result: %v", err)) + } + return fmt.Sprintf(`{"result":%s}`, string(b)) +} + +func TestGenerator_Generate_ReturnsSuggestions(t *testing.T) { + t.Parallel() + + inner := `{ + "suggestions": [ + { + "file_type": "CLAUDE.md", + "category": "fix_friction", + "title": "Add lint run instructions", + "description": "Agents skip linting before committing", + "evidence": ["Lint errors not caught", "Had to fix golangci-lint errors manually"], + "priority": "high", + "diff": "--- a/CLAUDE.md\n+++ b/CLAUDE.md\n@@ -1 +1,2 @@\n # Project\n+Run golangci-lint before committing." + } + ] + }` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(inner) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + analysis := improve.PatternAnalysis{ + RepeatedFriction: []improve.FrictionPattern{ + { + Theme: "lint", + Count: 2, + Examples: []string{"Lint errors not caught", "Had to fix golangci-lint errors manually"}, + }, + }, + SessionCount: 2, + } + + contextFiles := []improve.ContextFile{ + { + Type: improve.ContextFileCLAUDEMD, + Path: "/project/CLAUDE.md", + Exists: true, + Content: "# Project\n", + SizeBytes: 10, + }, + } + + result, err := gen.Generate(context.Background(), analysis, contextFiles) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Suggestions) != 1 { + t.Fatalf("expected 1 suggestion, got %d", len(result.Suggestions)) + } + + s := result.Suggestions[0] + if s.FileType != improve.ContextFileCLAUDEMD { + t.Errorf("expected file_type CLAUDE.md, got %q", s.FileType) + } + if s.Category != "fix_friction" { + t.Errorf("expected category fix_friction, got %q", s.Category) + } + if s.Title != "Add lint run instructions" { + t.Errorf("expected title, got %q", s.Title) + } + if s.Priority != "high" { + t.Errorf("expected priority high, got %q", s.Priority) + } + if s.Status != "pending" { + t.Errorf("expected status pending, got %q", s.Status) + } + if s.ID == "" { + t.Error("expected non-empty ID") + } + if !strings.HasPrefix(s.ID, "sug-") { + t.Errorf("expected ID to start with 'sug-', got %q", s.ID) + } + if s.CreatedAt.IsZero() { + t.Error("expected non-zero CreatedAt") + } +} + +func TestGenerator_Generate_EmptySuggestions(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(emptySuggestions) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + result, err := gen.Generate(context.Background(), improve.PatternAnalysis{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Suggestions) != 0 { + t.Errorf("expected 0 suggestions, got %d", len(result.Suggestions)) + } +} + +func TestGenerator_Generate_InvalidJSON(t *testing.T) { + t.Parallel() + + inner := `not valid json at all` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(inner) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + _, err := gen.Generate(context.Background(), improve.PatternAnalysis{}, nil) + if err == nil { + t.Fatal("expected error for invalid JSON response") + } +} + +func TestGenerator_Generate_PromptContainsFriction(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(emptySuggestions) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + analysis := improve.PatternAnalysis{ + RepeatedFriction: []improve.FrictionPattern{ + { + Theme: "lint", + Count: 3, + Examples: []string{"lint failed repeatedly"}, + }, + }, + } + + // Verify that a prompt with friction patterns executes without error. + if _, err := gen.Generate(context.Background(), analysis, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGenerator_Generate_PromptIncludesContextFiles(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(emptySuggestions) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + contextFiles := []improve.ContextFile{ + { + Type: improve.ContextFileCLAUDEMD, + Path: "/project/CLAUDE.md", + Exists: true, + Content: "# My Project", + SizeBytes: 14, + }, + { + Type: improve.ContextFileAGENTSMD, + Path: "/project/AGENTS.md", + Exists: false, + }, + } + + // No panic and no error = prompt was constructed and sent correctly + result, err := gen.Generate(context.Background(), improve.PatternAnalysis{}, contextFiles) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Suggestions == nil { + t.Error("expected non-nil suggestions slice") + } +} + +func TestGenerator_Generate_IDsAreUnique(t *testing.T) { + t.Parallel() + + inner := `{ + "suggestions": [ + {"file_type": "CLAUDE.md", "category": "fix_friction", "title": "A", "description": "d", "evidence": [], "priority": "high", "diff": ""}, + {"file_type": "CLAUDE.md", "category": "add_rule", "title": "B", "description": "d", "evidence": [], "priority": "medium", "diff": ""} + ] + }` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(inner) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + result, err := gen.Generate(context.Background(), improve.PatternAnalysis{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Suggestions) != 2 { + t.Fatalf("expected 2 suggestions, got %d", len(result.Suggestions)) + } + + if result.Suggestions[0].ID == result.Suggestions[1].ID { + t.Error("expected unique IDs for different suggestions") + } +} + +func TestGenerator_Generate_CreatedAtIsSet(t *testing.T) { + t.Parallel() + + inner := `{ + "suggestions": [ + {"file_type": "CLAUDE.md", "category": "fix_friction", "title": "A", "description": "d", "evidence": [], "priority": "high", "diff": ""} + ] + }` + + before := time.Now() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(inner) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + result, err := gen.Generate(context.Background(), improve.PatternAnalysis{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + after := time.Now() + + if len(result.Suggestions) != 1 { + t.Fatalf("expected 1 suggestion") + } + + if result.Suggestions[0].CreatedAt.Before(before) || result.Suggestions[0].CreatedAt.After(after) { + t.Errorf("CreatedAt %v not within expected range [%v, %v]", result.Suggestions[0].CreatedAt, before, after) + } +} + +func TestGenerator_Generate_RunnerError(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "exit 1") + }, + } + + gen := &improve.Generator{Runner: runner} + + if _, err := gen.Generate(context.Background(), improve.PatternAnalysis{}, nil); err == nil { + t.Fatal("expected error when runner fails") + } +} + +func TestGenerator_Generate_ParsesPromptRecommendationFields(t *testing.T) { + t.Parallel() + + inner := `{ + "suggestions": [ + { + "target_kind": "prompt_recommendation", + "file_type": "CLAUDE.md", + "file_path": "/project/CLAUDE.md", + "category": "missing_context", + "title": "Add lint verification reminder", + "description": "The agent kept missing the repo lint expectation.", + "evidence": ["Run golangci-lint before committing"], + "priority": "high", + "copyable_prompt": "Before finishing, run gofmt and golangci-lint and report the result.", + "suggested_instruction": "Always verify fmt and lint before claiming completion." + } + ] + }` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(inner) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("printf '%%s' '%s'", resp)) + }, + } + + gen := &improve.Generator{Runner: runner} + + result, err := gen.Generate(context.Background(), improve.PatternAnalysis{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Suggestions) != 1 { + t.Fatalf("expected 1 suggestion, got %d", len(result.Suggestions)) + } + + got := result.Suggestions[0] + if got.TargetKind != "prompt_recommendation" { + t.Fatalf("expected prompt recommendation target, got %q", got.TargetKind) + } + if got.CopyablePrompt == "" { + t.Fatal("expected copyable prompt to be populated") + } + if got.SuggestedInstruction == "" { + t.Fatal("expected suggested instruction to be populated") + } +} + +func TestGenerator_Generate_PromptIncludesStructuredSignals(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + script := ` +input=$(cat) +case "$input" in + *""* ) ;; + * ) exit 11 ;; +esac +case "$input" in + *""* ) ;; + * ) exit 12 ;; +esac +case "$input" in + *""* ) ;; + * ) exit 13 ;; +esac +case "$input" in + *"copyable_prompt"* ) ;; + * ) exit 14 ;; +esac +case "$input" in + *"suggested_instruction"* ) ;; + * ) exit 15 ;; +esac +case "$input" in + *"target_kind"* ) ;; + * ) exit 16 ;; +esac +printf '%s' '` + buildCLIResponse(emptySuggestions) + `' +` + return exec.CommandContext(ctx, "sh", "-c", script) + }, + } + + gen := &improve.Generator{Runner: runner} + + _, err := gen.Generate(context.Background(), improve.PatternAnalysis{ + RepeatedInstructions: []improve.RecurringSignal{ + {Value: "Run golangci-lint before committing", Count: 2, Evidence: []string{"User repeated lint expectation"}}, + }, + MissingContextSignals: []improve.RecurringSignal{ + {Value: "Repo requires canary after prompt changes", Count: 2}, + }, + SkillOpportunities: []improve.SkillOpportunity{ + { + SkillName: "project:go-linting", + SkillPath: ".codex/skills/go-linting/SKILL.md", + Count: 2, + MissingInstruction: "Warn about trailing nolint comments on signatures", + }, + }, + }, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/entire/cli/improve/improve.go b/cmd/entire/cli/improve/improve.go new file mode 100644 index 000000000..c7c6188cc --- /dev/null +++ b/cmd/entire/cli/improve/improve.go @@ -0,0 +1,121 @@ +// Package improve provides context file detection, friction analysis, and +// AI-powered improvement suggestions for project context files. +package improve + +import ( + "time" + + "github.com/entireio/cli/cmd/entire/cli/facets" +) + +// ContextFileType identifies the type of context file. +type ContextFileType string + +const ( + // ContextFileCLAUDEMD represents a CLAUDE.md context file. + ContextFileCLAUDEMD ContextFileType = "CLAUDE.md" + // ContextFileAGENTSMD represents an AGENTS.md context file. + ContextFileAGENTSMD ContextFileType = "AGENTS.md" + // ContextFileCursorRules represents a .cursorrules context file. + ContextFileCursorRules ContextFileType = ".cursorrules" + // ContextFileGemini represents a .gemini/settings.json context file. + ContextFileGemini ContextFileType = ".gemini/settings.json" +) + +// ContextFile represents a detected context file in the project. +type ContextFile struct { + Type ContextFileType `json:"type"` + Path string `json:"path"` + Exists bool `json:"exists"` + Content string `json:"content,omitempty"` + SizeBytes int `json:"size_bytes"` +} + +// Suggestion represents a proposed change to a context file. +type Suggestion struct { + ID string `json:"id"` + TargetKind string `json:"target_kind"` + FileType ContextFileType `json:"file_type,omitempty"` + FilePath string `json:"file_path,omitempty"` + SkillName string `json:"skill_name,omitempty"` + Category string `json:"category"` + Title string `json:"title"` + Description string `json:"description"` + Evidence []string `json:"evidence"` + Priority string `json:"priority"` // "high", "medium", "low" + CopyablePrompt string `json:"copyable_prompt,omitempty"` + SuggestedInstruction string `json:"suggested_instruction,omitempty"` + Diff string `json:"diff,omitempty"` + CreatedAt time.Time `json:"created_at"` + Status string `json:"status"` // "pending", "accepted", "rejected" +} + +// ImprovementReport is the output of `entire improve`. +type ImprovementReport struct { + ContextFiles []ContextFile `json:"context_files"` + Suggestions []Suggestion `json:"suggestions"` + Facets FacetSummary `json:"facets"` + FacetCounts FacetCounts `json:"facet_counts"` + SessionsUsed int `json:"sessions_used"` + FrictionTotal int `json:"friction_total"` + PatternsFound int `json:"patterns_found"` +} + +// FrictionPattern represents a recurring friction theme with evidence. +type FrictionPattern struct { + Theme string // Normalized theme + Count int // Occurrences across sessions + Examples []string // Raw friction text from summaries + AffectedSessions []string // Checkpoint IDs + TranscriptExcerpt string // Condensed transcript excerpt around friction (from deep-read) +} + +// RecurringSignal represents a repeated structured signal across sessions. +type RecurringSignal struct { + Value string `json:"value"` + Count int `json:"count"` + Evidence []string `json:"evidence,omitempty"` + AffectedSessions []string `json:"affected_sessions,omitempty"` +} + +// SkillOpportunity identifies a skill that should likely be tightened. +type SkillOpportunity struct { + SkillName string `json:"skill_name"` + SkillPath string `json:"skill_path,omitempty"` + Count int `json:"count"` + Friction []string `json:"friction,omitempty"` + MissingInstruction string `json:"missing_instruction,omitempty"` + AffectedSessions []string `json:"affected_sessions,omitempty"` +} + +// PatternAnalysis contains extracted patterns from multiple sessions. +type PatternAnalysis struct { + RepeatedFriction []FrictionPattern `json:"repeated_friction,omitempty"` + RepeatedInstructions []RecurringSignal `json:"repeated_instructions,omitempty"` + MissingContextSignals []RecurringSignal `json:"missing_context_signals,omitempty"` + FailureLoops []RecurringSignal `json:"failure_loops,omitempty"` + SkillOpportunities []SkillOpportunity `json:"skill_opportunities,omitempty"` + RepoLearnings []string `json:"repo_learnings,omitempty"` + WorkflowLearnings []string `json:"workflow_learnings,omitempty"` + OpenItems []string `json:"open_items,omitempty"` + SessionCount int `json:"session_count"` +} + +// FacetCounts summarizes how many structured signals were found. +type FacetCounts struct { + RepeatedInstructions int `json:"repeated_instructions"` + MissingContext int `json:"missing_context"` + FailureLoops int `json:"failure_loops"` + SkillSignals int `json:"skill_signals"` +} + +// FacetSummary exposes the raw structured signals that powered analysis. +type FacetSummary struct { + RepeatedInstructions []RecurringSignal `json:"repeated_instructions,omitempty"` + MissingContext []RecurringSignal `json:"missing_context,omitempty"` + FailureLoops []RecurringSignal `json:"failure_loops,omitempty"` + SkillOpportunities []SkillOpportunity `json:"skill_opportunities,omitempty"` +} + +// SessionFacetsAlias keeps the public improve package tied to the extracted facet model. +type SessionFacetsAlias = facets.SessionFacets diff --git a/cmd/entire/cli/improve_cmd.go b/cmd/entire/cli/improve_cmd.go new file mode 100644 index 000000000..37f5f195f --- /dev/null +++ b/cmd/entire/cli/improve_cmd.go @@ -0,0 +1,505 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + checkpointid "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/facets" + "github.com/entireio/cli/cmd/entire/cli/improve" + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/llmcli" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/stringutil" + "github.com/entireio/cli/cmd/entire/cli/summarize" + "github.com/entireio/cli/cmd/entire/cli/termstyle" + "github.com/spf13/cobra" +) + +func newImproveCmd() *cobra.Command { + var last int + var dryRun bool + var outputJSON bool + + cmd := &cobra.Command{ + Use: "improve", + Short: "Suggest improvements to project context files based on session patterns", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + w := cmd.OutOrStdout() + + if checkDisabledGuard(ctx, w) { + return nil + } + + if !settings.IsSummarizeEnabled(ctx) { + fmt.Fprintln(w, "Summarization is required for improve. Enable it in .entire/settings.json:") + fmt.Fprintln(w, ` { "strategy_options": { "summarize": { "enabled": true } } }`) + return nil + } + + return runImprove(ctx, w, last, dryRun, outputJSON) + }, + } + + cmd.Flags().IntVar(&last, "last", 10, "number of recent sessions to analyze") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show friction patterns only, no AI call, no transcript read") + cmd.Flags().BoolVar(&outputJSON, "json", false, "output as JSON instead of styled terminal output") + + return cmd +} + +// runImprove fetches session data from the SQLite cache, refreshes it if stale, +// then analyzes friction patterns and optionally generates context file improvements. +func runImprove(ctx context.Context, w io.Writer, last int, dryRun bool, outputJSON bool) error { + worktreeRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("not in a git repository: %w", err) + } + entireDir := filepath.Join(worktreeRoot, paths.EntireDir) + + idb, err := insightsdb.Open(filepath.Join(entireDir, "insights.db")) + if err != nil { + return fmt.Errorf("open insights cache: %w", err) + } + defer func() { _ = idb.Close() }() + + // Non-fatal: continue with whatever is in the cache. + refreshCacheIfStale(ctx, idb) //nolint:errcheck,gosec // Non-fatal; continue with stale cache + + // Generate summaries for recent sessions that lack them. + if !dryRun { + backfillSummaries(ctx, w, idb, last) + backfillFacets(ctx, idb, last) + } + + // Fetch the last N sessions for summary stats. + rows, err := idb.QueryLastNSessions(ctx, last) + if err != nil { + return fmt.Errorf("query sessions: %w", err) + } + + // Count total friction items across all sessions. + frictionTotal := 0 + for _, r := range rows { + frictionTotal += len(r.Friction) + } + + // Use keyword-based theme classification for pattern detection. + // This groups friction by theme (lint, api, conflict, etc.) instead of exact text match. + summaries := sessionRowsToSummaries(rows) + analysis := improve.AnalyzePatterns(summaries) + + if dryRun { + if outputJSON { + return renderImproveJSONDryRunThemes(w, analysis, len(rows), frictionTotal) + } + renderImproveTerminalDryRun(w, analysis, len(rows), frictionTotal) + return nil + } + + // Deep-read transcripts for top friction themes. + if err = attachTranscriptExcerpts(ctx, idb, analysis.RepeatedFriction, worktreeRoot); err != nil { + _ = err // Non-fatal: proceed without transcript excerpts. + } + + // Detect context files. + contextFiles := improve.DetectContextFiles(worktreeRoot) + + gen := improve.Generator{Runner: &llmcli.Runner{}} + result, err := gen.Generate(ctx, analysis, contextFiles) + if err != nil { + return fmt.Errorf("generate suggestions: %w", err) + } + + report := improve.ImprovementReport{ + ContextFiles: contextFiles, + Suggestions: result.Suggestions, + Facets: facetSummary(analysis), + FacetCounts: facetCounts(analysis), + SessionsUsed: len(rows), + FrictionTotal: frictionTotal, + PatternsFound: analysisPatternCount(analysis), + } + + if outputJSON { + return renderImproveJSON(w, report) + } + renderImproveTerminal(w, report) + if result.Usage != nil { + renderUsageLine(w, result.Usage) + } + return nil +} + +// attachTranscriptExcerpts fetches transcript excerpts for top friction patterns and +// attaches them in-place. Uses the pattern's AffectedSessions to find relevant checkpoints. +// Errors are non-fatal; unreadable sessions are silently skipped. +func attachTranscriptExcerpts(ctx context.Context, _ *insightsdb.InsightsDB, patterns []improve.FrictionPattern, _ string) error { + repo, err := openRepository(ctx) + if err != nil { + return fmt.Errorf("open git repository: %w", err) + } + store := checkpoint.NewGitStore(repo) + + // Limit to top 3 friction themes. + limit := 3 + if len(patterns) < limit { + limit = len(patterns) + } + + for i := range patterns[:limit] { + cpIDs := patterns[i].AffectedSessions + + // Limit to top 2 sessions per theme. + sessionLimit := 2 + if len(cpIDs) < sessionLimit { + sessionLimit = len(cpIDs) + } + + var excerpts []string + for _, cpIDStr := range cpIDs[:sessionLimit] { + cpID, parseErr := checkpointid.NewCheckpointID(cpIDStr) + if parseErr != nil { + continue + } + + content, readErr := store.ReadSessionContent(ctx, cpID, 0) + if readErr != nil { + continue + } + + condensed, buildErr := summarize.BuildCondensedTranscriptFromBytes(content.Transcript, content.Metadata.Agent) + if buildErr != nil || len(condensed) == 0 { + continue + } + + formatted := summarize.FormatCondensedTranscript(summarize.Input{Transcript: condensed}) + excerpt := truncateString(formatted, 2000) + if excerpt != "" { + excerpts = append(excerpts, excerpt) + } + } + + if len(excerpts) > 0 { + patterns[i].TranscriptExcerpt = strings.Join(excerpts, "\n---\n") + } + } + + return nil +} + +// sessionRowsToSummaries converts insightsdb rows into improve.SessionSummaryData values. +func sessionRowsToSummaries(rows []insightsdb.SessionRow) []improve.SessionSummaryData { + summaries := make([]improve.SessionSummaryData, 0, len(rows)) + for _, r := range rows { + s := improve.SessionSummaryData{ + CheckpointID: r.CheckpointID, + Friction: r.Friction, + Facets: r.Facets, + } + for _, l := range r.Learnings { + s.Learnings = append(s.Learnings, improve.LearningEntry{ + Scope: l.Scope, + Finding: l.Finding, + Path: l.Path, + }) + } + summaries = append(summaries, s) + } + return summaries +} + +// truncateString truncates s to at most maxLen runes, appending "..." if truncated. +func truncateString(s string, maxLen int) string { + return stringutil.TruncateRunes(s, maxLen, "...") +} + +// renderImproveJSON marshals the full report to JSON and writes it to w. +func renderImproveJSON(w io.Writer, report improve.ImprovementReport) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return fmt.Errorf("marshal improve report: %w", err) + } + return nil +} + +// renderImproveJSONDryRunThemes marshals the dry-run theme-grouped friction data to JSON. +func renderImproveJSONDryRunThemes(w io.Writer, analysis improve.PatternAnalysis, sessionCount, frictionTotal int) error { + type themeJSON struct { + Theme string `json:"theme"` + Count int `json:"count"` + Examples []string `json:"examples"` + Sessions []string `json:"sessions"` + } + type dryRunReport struct { + SessionsAnalyzed int `json:"sessions_analyzed"` + FrictionTotal int `json:"friction_total"` + RecurringThemes []themeJSON `json:"recurring_themes"` + } + themes := make([]themeJSON, 0, len(analysis.RepeatedFriction)) + for _, p := range analysis.RepeatedFriction { + themes = append(themes, themeJSON{ + Theme: p.Theme, + Count: p.Count, + Examples: p.Examples, + Sessions: p.AffectedSessions, + }) + } + report := dryRunReport{ + SessionsAnalyzed: sessionCount, + FrictionTotal: frictionTotal, + RecurringThemes: themes, + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return fmt.Errorf("marshal dry-run report: %w", err) + } + return nil +} + +// renderImproveTerminal writes a styled terminal view of the improvement report. +func renderImproveTerminal(w io.Writer, report improve.ImprovementReport) { + s := termstyle.New(w) + + fmt.Fprintln(w, s.Render(s.Bold, "Entire Improve")) + fmt.Fprintf(w, "Analyzed %d sessions | %d friction points | %d patterns found\n\n", + report.SessionsUsed, report.FrictionTotal, report.PatternsFound) + + // Context Files section. + fmt.Fprintln(w, s.SectionRule("Context Files")) + for _, cf := range report.ContextFiles { + if cf.Exists { + line := fmt.Sprintf(" %s %d bytes", cf.Type, cf.SizeBytes) + fmt.Fprintln(w, s.Render(s.Bold, line)) + } else { + line := fmt.Sprintf(" %s missing", cf.Type) + fmt.Fprintln(w, s.Render(s.Gray, line)) + } + } + fmt.Fprintln(w) + + // Suggestions section. + fmt.Fprintln(w, s.SectionRule("Top Recommendations")) + if len(report.Suggestions) == 0 { + fmt.Fprintln(w, " No suggestions generated.") + } + for i, sug := range report.Suggestions { + // Title line with priority. + titleLine := fmt.Sprintf(" %d. %s %s", i+1, sug.Title, sug.Priority) + fmt.Fprintln(w, s.Render(s.Bold, titleLine)) + + target := sug.TargetKind + switch { + case sug.SkillName != "": + target = sug.SkillName + case sug.FilePath != "": + target = sug.FilePath + case sug.FileType != "": + target = string(sug.FileType) + } + metaLine := fmt.Sprintf(" %s → %s", sug.Category, target) + fmt.Fprintln(w, s.Render(s.Dim, metaLine)) + + if sug.Description != "" { + fmt.Fprintf(w, " %s\n", sug.Description) + } + if sug.CopyablePrompt != "" { + fmt.Fprintf(w, " Prompt: %s\n", sug.CopyablePrompt) + } + if sug.SuggestedInstruction != "" { + fmt.Fprintf(w, " Instruction: %s\n", sug.SuggestedInstruction) + } + + if sug.Diff != "" { + fmt.Fprintln(w) + renderDiff(w, s, sug.Diff) + } + fmt.Fprintln(w) + } + + renderSuggestionGroup(w, s, "Prompt Changes", report.Suggestions, "prompt_recommendation") + renderSuggestionGroup(w, s, "Project Skill Updates", report.Suggestions, "skill_recommendation") +} + +// renderImproveTerminalDryRun writes a styled terminal view of the dry-run friction data. +func renderImproveTerminalDryRun(w io.Writer, analysis improve.PatternAnalysis, sessionCount, frictionTotal int) { + s := termstyle.New(w) + + fmt.Fprintln(w, s.Render(s.Bold, "Entire Improve (dry run)")) + fmt.Fprintf(w, "Analyzed %d sessions | %d friction points | %d signals found\n\n", + sessionCount, frictionTotal, analysisPatternCount(analysis)) + + fmt.Fprintln(w, s.SectionRule("Recurring Friction Themes")) + if len(analysis.RepeatedFriction) == 0 { + fmt.Fprintln(w, " No recurring friction themes found.") + return + } + for _, p := range analysis.RepeatedFriction { + headerLine := fmt.Sprintf(" [%dx] %s (%d sessions)", p.Count, p.Theme, len(p.AffectedSessions)) + fmt.Fprintln(w, s.Render(s.Bold, headerLine)) + limit := 3 + if len(p.Examples) < limit { + limit = len(p.Examples) + } + for _, ex := range p.Examples[:limit] { + fmt.Fprintln(w, s.Render(s.Gray, " "+ex)) + } + } + + renderRecurringSignals(w, s, "Repeated Instructions", analysis.RepeatedInstructions) + renderRecurringSignals(w, s, "Missing Context", analysis.MissingContextSignals) + + fmt.Fprintln(w) + fmt.Fprintln(w, s.SectionRule("Project Skill Updates")) + if len(analysis.SkillOpportunities) == 0 { + fmt.Fprintln(w, " No recurring skill-related opportunities found.") + return + } + for _, opportunity := range analysis.SkillOpportunities { + fmt.Fprintf(w, " [%dx] %s\n", opportunity.Count, opportunity.SkillName) + if opportunity.MissingInstruction != "" { + fmt.Fprintf(w, " %s\n", opportunity.MissingInstruction) + } + } +} + +// renderUsageLine prints a single-line cost/token summary after the report. +func renderUsageLine(w io.Writer, usage *llmcli.UsageInfo) { + s := termstyle.New(w) + tokens := termstyle.FormatTokenCount(usage.InputTokens + usage.OutputTokens) + line := fmt.Sprintf("\nCost: $%.4f (%s tokens)", usage.TotalCostUSD, tokens) + fmt.Fprintln(w, s.Render(s.Dim, line)) +} + +// renderDiff writes a unified diff with colored lines to w. +// Lines starting with '+' are rendered in green, '-' in red, '@@' in cyan, +// and all other lines in dim. +func renderDiff(w io.Writer, s termstyle.Styles, diff string) { + for _, line := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(line, "@@"): + fmt.Fprintln(w, s.Render(s.Cyan, line)) + case strings.HasPrefix(line, "+"): + fmt.Fprintln(w, s.Render(s.Green, line)) + case strings.HasPrefix(line, "-"): + fmt.Fprintln(w, s.Render(s.Red, line)) + default: + fmt.Fprintln(w, s.Render(s.Dim, line)) + } + } +} + +func renderSuggestionGroup(w io.Writer, s termstyle.Styles, title string, suggestions []improve.Suggestion, targetKind string) { + fmt.Fprintln(w, s.SectionRule(title)) + count := 0 + for _, suggestion := range suggestions { + if suggestion.TargetKind != targetKind { + continue + } + count++ + fmt.Fprintf(w, " %d. %s\n", count, suggestion.Title) + } + if count == 0 { + fmt.Fprintln(w, " No suggestions in this category.") + } + fmt.Fprintln(w) +} + +func renderRecurringSignals(w io.Writer, s termstyle.Styles, title string, signals []improve.RecurringSignal) { + fmt.Fprintln(w) + fmt.Fprintln(w, s.SectionRule(title)) + if len(signals) == 0 { + fmt.Fprintln(w, " None found.") + return + } + for _, signal := range signals { + fmt.Fprintf(w, " [%dx] %s\n", signal.Count, signal.Value) + } +} + +func analysisPatternCount(analysis improve.PatternAnalysis) int { + return len(analysis.RepeatedFriction) + + len(analysis.RepeatedInstructions) + + len(analysis.MissingContextSignals) + + len(analysis.FailureLoops) + + len(analysis.SkillOpportunities) +} + +func facetCounts(analysis improve.PatternAnalysis) improve.FacetCounts { + return improve.FacetCounts{ + RepeatedInstructions: len(analysis.RepeatedInstructions), + MissingContext: len(analysis.MissingContextSignals), + FailureLoops: len(analysis.FailureLoops), + SkillSignals: len(analysis.SkillOpportunities), + } +} + +func facetSummary(analysis improve.PatternAnalysis) improve.FacetSummary { + return improve.FacetSummary{ + RepeatedInstructions: analysis.RepeatedInstructions, + MissingContext: analysis.MissingContextSignals, + FailureLoops: analysis.FailureLoops, + SkillOpportunities: analysis.SkillOpportunities, + } +} + +func backfillFacets(ctx context.Context, idb *insightsdb.InsightsDB, lastN int) { + rows, err := idb.QueryLastNSessions(ctx, lastN) + if err != nil { + return + } + + repo, err := openRepository(ctx) + if err != nil { + return + } + + store := checkpoint.NewGitStore(repo) + extractor := &facets.Extractor{Runner: &llmcli.Runner{}} + + for _, row := range rows { + if row.HasFacets { + continue + } + + cpID, parseErr := checkpointid.NewCheckpointID(row.CheckpointID) + if parseErr != nil { + continue + } + + content, readErr := store.ReadSessionContent(ctx, cpID, row.SessionIndex) + if readErr != nil || len(content.Transcript) == 0 { + continue + } + + condensed, buildErr := summarize.BuildCondensedTranscriptFromBytes(content.Transcript, content.Metadata.Agent) + if buildErr != nil || len(condensed) == 0 { + continue + } + + formatted := summarize.FormatCondensedTranscript(summarize.Input{ + Transcript: condensed, + FilesTouched: row.FilesTouched, + }) + + extracted, _, extractErr := extractor.Extract(ctx, formatted) + if extractErr != nil || extracted == nil { + continue + } + + row.Facets = *extracted + row.HasFacets = true + if updateErr := idb.UpdateSessionFacets(ctx, row); updateErr != nil { + continue + } + } +} diff --git a/cmd/entire/cli/improve_cmd_test.go b/cmd/entire/cli/improve_cmd_test.go new file mode 100644 index 000000000..3bab40588 --- /dev/null +++ b/cmd/entire/cli/improve_cmd_test.go @@ -0,0 +1,115 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/facets" + "github.com/entireio/cli/cmd/entire/cli/improve" + "github.com/entireio/cli/cmd/entire/cli/insightsdb" +) + +func TestSessionRowsToSummaries_CopiesStructuredFacets(t *testing.T) { + t.Parallel() + + rows := []insightsdb.SessionRow{ + { + CheckpointID: "cp-1", + Facets: facets.SessionFacets{ + RepeatedUserInstructions: []facets.RepeatedInstruction{ + {Instruction: "Run golangci-lint before committing"}, + }, + SkillSignals: []facets.SkillSignal{ + {SkillName: "project:go-linting", MissingInstruction: "Warn about trailing nolint comments"}, + }, + }, + }, + } + + got := sessionRowsToSummaries(rows) + + if len(got) != 1 { + t.Fatalf("expected 1 summary, got %d", len(got)) + } + if len(got[0].Facets.RepeatedUserInstructions) != 1 { + t.Fatalf("expected repeated instructions to be copied, got %+v", got[0].Facets) + } + if got[0].Facets.SkillSignals[0].SkillName != "project:go-linting" { + t.Fatalf("unexpected copied skill signal: %+v", got[0].Facets.SkillSignals[0]) + } +} + +func TestRenderImproveTerminalDryRun_ShowsStructuredSignals(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + renderImproveTerminalDryRun(&buf, improve.PatternAnalysis{ + RepeatedInstructions: []improve.RecurringSignal{ + {Value: "Run golangci-lint before committing", Count: 2}, + }, + MissingContextSignals: []improve.RecurringSignal{ + {Value: "Repo requires canary after prompt changes", Count: 2}, + }, + SkillOpportunities: []improve.SkillOpportunity{ + {SkillName: "project:go-linting", Count: 2}, + }, + RepeatedFriction: []improve.FrictionPattern{ + {Theme: "lint", Count: 2}, + }, + }, 3, 6) + + out := buf.String() + for _, want := range []string{ + "Repeated Instructions", + "Missing Context", + "Project Skill Updates", + "Run golangci-lint before committing", + "project:go-linting", + } { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in output:\n%s", want, out) + } + } +} + +func TestRenderImproveTerminal_ShowsRecommendationSections(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + renderImproveTerminal(&buf, improve.ImprovementReport{ + Suggestions: []improve.Suggestion{ + { + Title: "Add lint verification reminder", + TargetKind: "prompt_recommendation", + Category: "missing_context", + Priority: "high", + CopyablePrompt: "Before finishing, run gofmt and golangci-lint.", + SuggestedInstruction: "Always verify fmt and lint before claiming completion.", + }, + { + Title: "Tighten go-linting skill", + TargetKind: "skill_recommendation", + SkillName: "project:go-linting", + Category: "skill_gap", + Priority: "high", + SuggestedInstruction: "Warn about trailing nolint comments on signatures.", + }, + }, + }) + + out := buf.String() + for _, want := range []string{ + "Top Recommendations", + "Prompt Changes", + "Project Skill Updates", + "Add lint verification reminder", + "Tighten go-linting skill", + } { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in output:\n%s", want, out) + } + } +} diff --git a/cmd/entire/cli/insights/insights.go b/cmd/entire/cli/insights/insights.go new file mode 100644 index 000000000..2e64a3ade --- /dev/null +++ b/cmd/entire/cli/insights/insights.go @@ -0,0 +1,84 @@ +package insights + +import ( + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent/types" +) + +// SessionScore represents a quality assessment of a single session. +type SessionScore struct { + CheckpointID string `json:"checkpoint_id"` + SessionID string `json:"session_id"` + Agent types.AgentType `json:"agent"` + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + Overall float64 `json:"overall"` // 0-100 composite + Breakdown ScoreBreakdown `json:"breakdown"` + TokensUsed int `json:"tokens_used"` + TurnCount int `json:"turn_count"` + FilesCount int `json:"files_count"` + FrictionCount int `json:"friction_count"` + HasSummary bool `json:"has_summary"` + ToolCallCount int `json:"tool_call_count"` + TopTools []string `json:"top_tools,omitempty"` + SkillsUsed []string `json:"skills_used,omitempty"` +} + +// ScoreBreakdown shows individual scoring dimensions (each 0-100). +type ScoreBreakdown struct { + TokenEfficiency float64 `json:"token_efficiency"` + FirstPassSuccess float64 `json:"first_pass_success"` + FrictionScore float64 `json:"friction_score"` + FocusScore float64 `json:"focus_score"` +} + +// SessionData is the input to ScoreSession — populated from CommittedMetadata. +// This decouples scoring from checkpoint types. +type SessionData struct { + TotalTokens int + FilesCount int + FrictionCount int + TurnCount int + OpenItemCount int + HasSummary bool +} + +// Trend represents a metric tracked over time. +type Trend struct { + Metric string `json:"metric"` + Direction string `json:"direction"` // "improving", "declining", "stable" + ChangePercent float64 `json:"change_percent"` + DataPoints []DataPoint `json:"data_points"` +} + +// DataPoint is a single observation in a trend. +type DataPoint struct { + Date time.Time `json:"date"` + Value float64 `json:"value"` + Label string `json:"label,omitempty"` +} + +// AgentComparison summarizes performance differences between agents. +type AgentComparison struct { + Agent types.AgentType `json:"agent"` + SessionCount int `json:"session_count"` + AvgScore float64 `json:"avg_score"` + AvgTokens int `json:"avg_tokens"` + AvgTurns float64 `json:"avg_turns"` + AvgFriction float64 `json:"avg_friction"` + TopStrength string `json:"top_strength"` + TopWeakness string `json:"top_weakness"` + TopTools []string `json:"top_tools,omitempty"` + AvgToolCalls float64 `json:"avg_tool_calls"` +} + +// Report is the full output of `entire insights`. +type Report struct { + GeneratedAt time.Time `json:"generated_at"` + Period string `json:"period"` + Sessions []SessionScore `json:"sessions"` + Trends []Trend `json:"trends"` + Comparisons []AgentComparison `json:"comparisons"` + SessionCount int `json:"session_count"` +} diff --git a/cmd/entire/cli/insights/scorer.go b/cmd/entire/cli/insights/scorer.go new file mode 100644 index 000000000..866f0e87a --- /dev/null +++ b/cmd/entire/cli/insights/scorer.go @@ -0,0 +1,91 @@ +package insights + +import ( + "math" +) + +// Weights for composite score. +const ( + WeightTokenEfficiency = 0.30 + WeightFirstPassSuccess = 0.30 + WeightFriction = 0.25 + WeightFocus = 0.15 +) + +// ScoreSession computes a score breakdown from session data. +// This is a pure function — no I/O. +// The Overall score is NOT computed here — call ComputeOverall with the result. +func ScoreSession(data SessionData) ScoreBreakdown { + return ScoreBreakdown{ + TokenEfficiency: scoreTokenEfficiency(data), + FirstPassSuccess: scoreFirstPassSuccess(data), + FrictionScore: scoreFriction(data), + FocusScore: scoreFocus(data), + } +} + +// ComputeOverall applies weights to a breakdown and returns the composite 0-100 score +// rounded to 1 decimal place. +func ComputeOverall(b ScoreBreakdown) float64 { + raw := b.TokenEfficiency*WeightTokenEfficiency + + b.FirstPassSuccess*WeightFirstPassSuccess + + b.FrictionScore*WeightFriction + + b.FocusScore*WeightFocus + return math.Round(raw*10) / 10 +} + +// clampScore clamps s to the range [0, 100]. +func clampScore(s float64) float64 { + if s < 0 { + return 0 + } + if s > 100 { + return 100 + } + return s +} + +// scoreTokenEfficiency uses a sigmoid centered at 500k tokens/turn. +// Lower tokens per turn = higher efficiency. +// ~100k/turn → ~85, ~500k → 50, ~2M → ~12. +// Returns 50 (neutral) when turns==0 or totalTokens==0. +func scoreTokenEfficiency(data SessionData) float64 { + if data.TurnCount == 0 || data.TotalTokens == 0 { + return 50 + } + tokensPerTurn := float64(data.TotalTokens) / float64(data.TurnCount) + return clampScore(100 / (1 + math.Pow(tokensPerTurn/500000, 1.5))) +} + +// scoreFirstPassSuccess starts at 90, deducting for friction, extra turns, +// and open items. Returns 50 (neutral) when HasSummary is false. +func scoreFirstPassSuccess(data SessionData) float64 { + if !data.HasSummary { + return 50 + } + score := 90.0 + score -= float64(data.FrictionCount) * 5 + if data.TurnCount > 5 { + score -= float64(data.TurnCount-5) * 2 + } + score -= float64(data.OpenItemCount) * 3 + return clampScore(score) +} + +// scoreFriction returns 100 - frictionCount*15, clamped to [0,100]. +// 0 friction → 100, 3 → 55, 5 → 25, 7+ → 0. +func scoreFriction(data SessionData) float64 { + return clampScore(100 - float64(data.FrictionCount)*15) +} + +// scoreFocus uses a gaussian curve on turns-per-file ratio. +// Peak at ratio=1 (1 turn per file). Gradual falloff for scattered or over-focused sessions. +// Returns 50 (neutral) when turns==0 or files==0. +func scoreFocus(data SessionData) float64 { + if data.TurnCount == 0 || data.FilesCount == 0 { + return 50 + } + ratio := float64(data.TurnCount) / float64(data.FilesCount) + deviation := math.Log2(math.Max(ratio, 0.1)) + return clampScore(95 * math.Exp(-deviation*deviation/4)) +} diff --git a/cmd/entire/cli/insights/scorer_test.go b/cmd/entire/cli/insights/scorer_test.go new file mode 100644 index 000000000..74969cee4 --- /dev/null +++ b/cmd/entire/cli/insights/scorer_test.go @@ -0,0 +1,334 @@ +package insights + +import ( + "math" + "testing" +) + +func TestScoreSession_TokenEfficiency(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data SessionData + wantMin float64 + wantMax float64 + wantExact *float64 + }{ + { + name: "zero turns returns neutral 50", + data: SessionData{TotalTokens: 1000, TurnCount: 0}, + wantExact: ptr(50.0), + }, + { + name: "zero tokens returns neutral 50", + data: SessionData{TotalTokens: 0, TurnCount: 5}, + wantExact: ptr(50.0), + }, + { + name: "both zero returns neutral 50", + data: SessionData{TotalTokens: 0, TurnCount: 0}, + wantExact: ptr(50.0), + }, + { + name: "100k tokens/turn scores high", + data: SessionData{TotalTokens: 500000, TurnCount: 5}, // 100k/turn + wantMin: 80.0, + wantMax: 100.0, + }, + { + name: "500k tokens/turn scores ~50", + data: SessionData{TotalTokens: 500000, TurnCount: 1}, // 500k/turn + wantMin: 45.0, + wantMax: 55.0, + }, + { + name: "2M tokens/turn scores low", + data: SessionData{TotalTokens: 2000000, TurnCount: 1}, // 2M/turn + wantMin: 5.0, + wantMax: 20.0, + }, + { + name: "real-world: 3M tokens 6 turns", + data: SessionData{TotalTokens: 3000000, TurnCount: 6}, // 500k/turn + wantMin: 45.0, + wantMax: 55.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ScoreSession(tt.data) + if tt.wantExact != nil { + if got.TokenEfficiency != *tt.wantExact { + t.Errorf("TokenEfficiency = %v, want %v", got.TokenEfficiency, *tt.wantExact) + } + return + } + if got.TokenEfficiency < tt.wantMin || got.TokenEfficiency > tt.wantMax { + t.Errorf("TokenEfficiency = %v, want [%v, %v]", got.TokenEfficiency, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestScoreSession_FirstPassSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data SessionData + wantExact *float64 + wantMin float64 + wantMax float64 + }{ + { + name: "no summary returns neutral 50", + data: SessionData{HasSummary: false, FrictionCount: 0, TurnCount: 3, OpenItemCount: 0}, + wantExact: ptr(50.0), + }, + { + name: "perfect session scores 90", + data: SessionData{HasSummary: true, FrictionCount: 0, TurnCount: 5, OpenItemCount: 0}, + wantMin: 90.0, + wantMax: 90.0, + }, + { + name: "friction deducts 5 per count", + data: SessionData{HasSummary: true, FrictionCount: 2, TurnCount: 5, OpenItemCount: 0}, + wantMin: 80.0, // 90 - 2*5 = 80 + wantMax: 80.0, + }, + { + name: "extra turns deduct 2 per turn over 5", + data: SessionData{HasSummary: true, FrictionCount: 0, TurnCount: 8, OpenItemCount: 0}, + wantMin: 84.0, // 90 - 3*2 = 84 + wantMax: 84.0, + }, + { + name: "open items deduct 3 each", + data: SessionData{HasSummary: true, FrictionCount: 0, TurnCount: 5, OpenItemCount: 2}, + wantMin: 84.0, // 90 - 2*3 = 84 + wantMax: 84.0, + }, + { + name: "clamped at 0 for severe friction", + data: SessionData{HasSummary: true, FrictionCount: 20, TurnCount: 5, OpenItemCount: 0}, + wantMin: 0.0, + wantMax: 0.0, + }, + { + name: "turns <= 5 do not deduct extra", + data: SessionData{HasSummary: true, FrictionCount: 0, TurnCount: 1, OpenItemCount: 0}, + wantMin: 90.0, + wantMax: 90.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ScoreSession(tt.data) + if tt.wantExact != nil { + if got.FirstPassSuccess != *tt.wantExact { + t.Errorf("FirstPassSuccess = %v, want %v", got.FirstPassSuccess, *tt.wantExact) + } + return + } + if got.FirstPassSuccess < tt.wantMin || got.FirstPassSuccess > tt.wantMax { + t.Errorf("FirstPassSuccess = %v, want [%v, %v]", got.FirstPassSuccess, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestScoreSession_FrictionScore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data SessionData + want float64 + }{ + {"zero friction is 100", SessionData{FrictionCount: 0}, 100.0}, + {"one friction is 85", SessionData{FrictionCount: 1}, 85.0}, + {"two friction is 70", SessionData{FrictionCount: 2}, 70.0}, + {"three friction is 55", SessionData{FrictionCount: 3}, 55.0}, + {"five friction is 25", SessionData{FrictionCount: 5}, 25.0}, + {"seven friction clamped to 0", SessionData{FrictionCount: 7}, 0.0}, + {"ten friction clamped to 0", SessionData{FrictionCount: 10}, 0.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ScoreSession(tt.data) + if got.FrictionScore != tt.want { + t.Errorf("FrictionScore = %v, want %v", got.FrictionScore, tt.want) + } + }) + } +} + +func TestScoreSession_FocusScore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data SessionData + wantMin float64 + wantMax float64 + }{ + { + name: "zero turns returns neutral 50", + data: SessionData{FilesCount: 5, TurnCount: 0}, + wantMin: 50.0, + wantMax: 50.0, + }, + { + name: "zero files returns neutral 50", + data: SessionData{FilesCount: 0, TurnCount: 5}, + wantMin: 50.0, + wantMax: 50.0, + }, + { + name: "both zero returns neutral 50", + data: SessionData{FilesCount: 0, TurnCount: 0}, + wantMin: 50.0, + wantMax: 50.0, + }, + { + name: "ratio 1.0 (turns/files) scores highest", + data: SessionData{FilesCount: 5, TurnCount: 5}, // ratio=1.0 + wantMin: 90.0, + wantMax: 95.0, + }, + { + name: "ratio 2.0 scores moderately", + data: SessionData{FilesCount: 5, TurnCount: 10}, // ratio=2.0 + wantMin: 70.0, + wantMax: 85.0, + }, + { + name: "ratio 0.5 scores moderately", + data: SessionData{FilesCount: 2, TurnCount: 1}, // ratio=0.5 + wantMin: 70.0, + wantMax: 85.0, + }, + { + name: "many turns per file scores low", + data: SessionData{FilesCount: 1, TurnCount: 10}, // ratio=10.0 + wantMin: 0.0, + wantMax: 15.0, + }, + { + name: "very scattered (ratio 0.1) scores low", + data: SessionData{FilesCount: 10, TurnCount: 1}, // ratio=0.1 + wantMin: 0.0, + wantMax: 15.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ScoreSession(tt.data) + if got.FocusScore < tt.wantMin || got.FocusScore > tt.wantMax { + t.Errorf("FocusScore = %v, want [%v, %v]", got.FocusScore, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestComputeOverall(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + b ScoreBreakdown + want float64 + }{ + { + name: "all 100 gives 100", + b: ScoreBreakdown{TokenEfficiency: 100, FirstPassSuccess: 100, FrictionScore: 100, FocusScore: 100}, + want: 100.0, + }, + { + name: "all 0 gives 0", + b: ScoreBreakdown{TokenEfficiency: 0, FirstPassSuccess: 0, FrictionScore: 0, FocusScore: 0}, + want: 0.0, + }, + { + name: "weighted sum: token=100 only", + // 100*0.30 + 0*0.30 + 0*0.25 + 0*0.15 = 30.0 + b: ScoreBreakdown{TokenEfficiency: 100, FirstPassSuccess: 0, FrictionScore: 0, FocusScore: 0}, + want: 30.0, + }, + { + name: "weighted sum: first pass=100 only", + // 0*0.30 + 100*0.30 + 0*0.25 + 0*0.15 = 30.0 + b: ScoreBreakdown{TokenEfficiency: 0, FirstPassSuccess: 100, FrictionScore: 0, FocusScore: 0}, + want: 30.0, + }, + { + name: "weighted sum: friction=100 only", + // 0*0.30 + 0*0.30 + 100*0.25 + 0*0.15 = 25.0 + b: ScoreBreakdown{TokenEfficiency: 0, FirstPassSuccess: 0, FrictionScore: 100, FocusScore: 0}, + want: 25.0, + }, + { + name: "weighted sum: focus=100 only", + // 0*0.30 + 0*0.30 + 0*0.25 + 100*0.15 = 15.0 + b: ScoreBreakdown{TokenEfficiency: 0, FirstPassSuccess: 0, FrictionScore: 0, FocusScore: 100}, + want: 15.0, + }, + { + name: "mixed values rounded to 1 decimal", + // 80*0.30 + 70*0.30 + 90*0.25 + 60*0.15 + // = 24 + 21 + 22.5 + 9 = 76.5 + b: ScoreBreakdown{TokenEfficiency: 80, FirstPassSuccess: 70, FrictionScore: 90, FocusScore: 60}, + want: 76.5, + }, + { + name: "rounding: result with fractional part", + // 75*0.30 + 75*0.30 + 75*0.25 + 75*0.15 = 75.0 + b: ScoreBreakdown{TokenEfficiency: 75, FirstPassSuccess: 75, FrictionScore: 75, FocusScore: 75}, + want: 75.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ComputeOverall(tt.b) + if math.Abs(got-tt.want) > 0.05 { + t.Errorf("ComputeOverall = %v, want %v", got, tt.want) + } + }) + } +} + +func TestClampScore(t *testing.T) { + t.Parallel() + + tests := []struct { + input float64 + want float64 + }{ + {-10, 0}, + {0, 0}, + {50, 50}, + {100, 100}, + {110, 100}, + } + + for _, tt := range tests { + got := clampScore(tt.input) + if got != tt.want { + t.Errorf("clampScore(%v) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func ptr(f float64) *float64 { return &f } diff --git a/cmd/entire/cli/insights/trends.go b/cmd/entire/cli/insights/trends.go new file mode 100644 index 000000000..70a505ffa --- /dev/null +++ b/cmd/entire/cli/insights/trends.go @@ -0,0 +1,235 @@ +package insights + +import ( + "math" + "sort" + + "github.com/entireio/cli/cmd/entire/cli/agent/types" +) + +// ComputeTrends analyzes score history to detect improvement or decline. +// It computes trends for: "overall_score", "token_usage", "friction", "turns_per_session". +// Method: compare first-half average to second-half average. +// |change| < 5% → "stable"; otherwise "improving" or "declining" +// (inverted for token_usage, friction, turns where lower is better). +// If < 2 data points, all trends are "stable". +func ComputeTrends(scores []SessionScore) []Trend { + metrics := []struct { + name string + extract func(SessionScore) float64 + lowerBetter bool + }{ + {"overall_score", func(s SessionScore) float64 { return s.Overall }, false}, + {"token_usage", func(s SessionScore) float64 { return float64(s.TokensUsed) }, true}, + {"friction", func(s SessionScore) float64 { return float64(s.FrictionCount) }, true}, + {"turns_per_session", func(s SessionScore) float64 { return float64(s.TurnCount) }, true}, + } + + trends := make([]Trend, 0, len(metrics)) + for _, m := range metrics { + trends = append(trends, computeTrendForMetric(scores, m.name, m.extract, m.lowerBetter)) + } + return trends +} + +// computeTrendForMetric computes a single trend for one metric. +func computeTrendForMetric(scores []SessionScore, metric string, extract func(SessionScore) float64, lowerBetter bool) Trend { + dataPoints := make([]DataPoint, 0, len(scores)) + for _, s := range scores { + dataPoints = append(dataPoints, DataPoint{ + Date: s.CreatedAt, + Value: extract(s), + Label: s.SessionID, + }) + } + + trend := Trend{ + Metric: metric, + Direction: "stable", + DataPoints: dataPoints, + } + + if len(scores) < 2 { + return trend + } + + // Split into first and second halves. + mid := len(scores) / 2 + firstHalf := scores[:mid] + secondHalf := scores[mid:] + + firstAvg := average(firstHalf, extract) + secondAvg := average(secondHalf, extract) + + if firstAvg == 0 { + return trend + } + + changePercent := (secondAvg - firstAvg) / math.Abs(firstAvg) * 100 + trend.ChangePercent = math.Round(math.Abs(changePercent)*10) / 10 + + if math.Abs(changePercent) < 5 { + return trend + } + + // Determine direction: for lower-is-better metrics, a decrease is "improving". + increased := changePercent > 0 + if lowerBetter { + if increased { + trend.Direction = "declining" + } else { + trend.Direction = "improving" + } + } else { + if increased { + trend.Direction = "improving" + } else { + trend.Direction = "declining" + } + } + + return trend +} + +// average computes the mean of the extracted value from a slice of scores. +func average(scores []SessionScore, extract func(SessionScore) float64) float64 { + if len(scores) == 0 { + return 0 + } + sum := 0.0 + for _, s := range scores { + sum += extract(s) + } + return sum / float64(len(scores)) +} + +// ComputeAgentComparisons groups scores by agent and computes averages. +// For each agent: avg score, avg tokens, avg turns, avg friction. +// TopStrength/TopWeakness: compare breakdown dimension averages, pick highest/lowest. +// Results are sorted by avg score descending. +func ComputeAgentComparisons(scores []SessionScore) []AgentComparison { + if len(scores) == 0 { + return nil + } + + type accumulator struct { + totalScore float64 + totalTokens int + totalTurns float64 + totalFriction float64 + totalTokenEff float64 + totalFirstPass float64 + totalFrictionSc float64 + totalFocus float64 + totalToolCalls int + toolCounts map[string]int + count int + } + + agentMap := make(map[types.AgentType]*accumulator) + for _, s := range scores { + acc, ok := agentMap[s.Agent] + if !ok { + acc = &accumulator{toolCounts: make(map[string]int)} + agentMap[s.Agent] = acc + } + acc.totalScore += s.Overall + acc.totalTokens += s.TokensUsed + acc.totalTurns += float64(s.TurnCount) + acc.totalFriction += float64(s.FrictionCount) + acc.totalTokenEff += s.Breakdown.TokenEfficiency + acc.totalFirstPass += s.Breakdown.FirstPassSuccess + acc.totalFrictionSc += s.Breakdown.FrictionScore + acc.totalFocus += s.Breakdown.FocusScore + acc.totalToolCalls += s.ToolCallCount + for _, tool := range s.TopTools { + acc.toolCounts[tool]++ + } + acc.count++ + } + + comparisons := make([]AgentComparison, 0, len(agentMap)) + for agent, acc := range agentMap { + n := float64(acc.count) + avgTokenEff := acc.totalTokenEff / n + avgFirstPass := acc.totalFirstPass / n + avgFrictionSc := acc.totalFrictionSc / n + avgFocus := acc.totalFocus / n + + topStrength, topWeakness := strengthAndWeakness(avgTokenEff, avgFirstPass, avgFrictionSc, avgFocus) + + comparisons = append(comparisons, AgentComparison{ + Agent: agent, + SessionCount: acc.count, + AvgScore: math.Round(acc.totalScore/n*10) / 10, + AvgTokens: int(math.Round(float64(acc.totalTokens) / n)), + AvgTurns: math.Round(acc.totalTurns/n*10) / 10, + AvgFriction: math.Round(acc.totalFriction/n*10) / 10, + TopStrength: topStrength, + TopWeakness: topWeakness, + TopTools: topNFromCounts(acc.toolCounts, 3), + AvgToolCalls: math.Round(float64(acc.totalToolCalls)/n*10) / 10, + }) + } + + // Sort by avg score descending. + sort.Slice(comparisons, func(i, j int) bool { + return comparisons[i].AvgScore > comparisons[j].AvgScore + }) + + return comparisons +} + +// strengthAndWeakness returns the dimension name with the highest and lowest average. +func strengthAndWeakness(tokenEff, firstPass, frictionSc, focus float64) (strength, weakness string) { + dims := []struct { + name string + value float64 + }{ + {"token_efficiency", tokenEff}, + {"first_pass_success", firstPass}, + {"friction_score", frictionSc}, + {"focus_score", focus}, + } + + maxVal := dims[0].value + minVal := dims[0].value + strength = dims[0].name + weakness = dims[0].name + + for _, d := range dims[1:] { + if d.value > maxVal { + maxVal = d.value + strength = d.name + } + if d.value < minVal { + minVal = d.value + weakness = d.name + } + } + return strength, weakness +} + +// topNFromCounts returns the top N keys from a count map, sorted by count descending. +func topNFromCounts(counts map[string]int, n int) []string { + if len(counts) == 0 { + return nil + } + type kv struct { + key string + count int + } + sorted := make([]kv, 0, len(counts)) + for k, v := range counts { + sorted = append(sorted, kv{k, v}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].count > sorted[j].count + }) + limit := min(n, len(sorted)) + result := make([]string, limit) + for i := range limit { + result[i] = sorted[i].key + } + return result +} diff --git a/cmd/entire/cli/insights/trends_test.go b/cmd/entire/cli/insights/trends_test.go new file mode 100644 index 000000000..bfdcb38c6 --- /dev/null +++ b/cmd/entire/cli/insights/trends_test.go @@ -0,0 +1,324 @@ +package insights + +import ( + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent/types" +) + +const ( + dirStable = "stable" + dirImproving = "improving" + dirDeclining = "declining" +) + +func TestComputeTrends_Empty(t *testing.T) { + t.Parallel() + + trends := ComputeTrends(nil) + if len(trends) != 4 { + t.Fatalf("expected 4 trends, got %d", len(trends)) + } + for _, tr := range trends { + if tr.Direction != dirStable { + t.Errorf("trend %q direction = %q, want stable", tr.Metric, tr.Direction) + } + if len(tr.DataPoints) != 0 { + t.Errorf("trend %q data points = %d, want 0", tr.Metric, len(tr.DataPoints)) + } + } +} + +func TestComputeTrends_SingleDataPoint(t *testing.T) { + t.Parallel() + + scores := []SessionScore{makeScore(1, 75, 1000, 3, 0)} + trends := ComputeTrends(scores) + + if len(trends) != 4 { + t.Fatalf("expected 4 trends, got %d", len(trends)) + } + for _, tr := range trends { + if tr.Direction != dirStable { + t.Errorf("trend %q direction = %q, want stable for single point", tr.Metric, tr.Direction) + } + } +} + +func TestComputeTrends_StableScore(t *testing.T) { + t.Parallel() + + // All scores the same → stable + scores := []SessionScore{ + makeScore(1, 75, 1000, 3, 0), + makeScore(2, 75, 1000, 3, 0), + makeScore(3, 75, 1000, 3, 0), + makeScore(4, 75, 1000, 3, 0), + } + trends := ComputeTrends(scores) + overall := findTrend(trends, "overall_score") + if overall == nil { + t.Fatal("overall_score trend not found") + } + if overall.Direction != dirStable { + t.Errorf("overall_score direction = %q, want stable", overall.Direction) + } + // change percent < 5% + if overall.ChangePercent >= 5.0 { + t.Errorf("ChangePercent = %v, want < 5", overall.ChangePercent) + } +} + +func TestComputeTrends_ImprovingScore(t *testing.T) { + t.Parallel() + + // Score improves significantly from first half to second half + scores := []SessionScore{ + makeScore(1, 40, 5000, 5, 2), + makeScore(2, 45, 5000, 5, 2), + makeScore(3, 75, 2000, 3, 0), + makeScore(4, 80, 2000, 3, 0), + } + trends := ComputeTrends(scores) + overall := findTrend(trends, "overall_score") + if overall == nil { + t.Fatal("overall_score trend not found") + } + if overall.Direction != dirImproving { + t.Errorf("overall_score direction = %q, want improving", overall.Direction) + } +} + +func TestComputeTrends_DecliningScore(t *testing.T) { + t.Parallel() + + // Score declines + scores := []SessionScore{ + makeScore(1, 80, 2000, 3, 0), + makeScore(2, 75, 2000, 3, 0), + makeScore(3, 45, 5000, 5, 2), + makeScore(4, 40, 5000, 5, 2), + } + trends := ComputeTrends(scores) + overall := findTrend(trends, "overall_score") + if overall == nil { + t.Fatal("overall_score trend not found") + } + if overall.Direction != dirDeclining { + t.Errorf("overall_score direction = %q, want declining", overall.Direction) + } +} + +func TestComputeTrends_TokenUsageInverted(t *testing.T) { + t.Parallel() + + // Token usage decreasing (lower is better) → improving + scores := []SessionScore{ + makeScore(1, 60, 8000, 4, 1), + makeScore(2, 60, 8000, 4, 1), + makeScore(3, 60, 1000, 4, 1), + makeScore(4, 60, 1000, 4, 1), + } + trends := ComputeTrends(scores) + tokenTrend := findTrend(trends, "token_usage") + if tokenTrend == nil { + t.Fatal("token_usage trend not found") + } + if tokenTrend.Direction != dirImproving { + t.Errorf("token_usage direction = %q, want improving (lower tokens = better)", tokenTrend.Direction) + } +} + +func TestComputeTrends_FrictionInverted(t *testing.T) { + t.Parallel() + + // Friction decreasing (lower is better) → improving + scores := []SessionScore{ + makeScoreWithFriction(1, 2), + makeScoreWithFriction(2, 2), + makeScoreWithFriction(3, 0), + makeScoreWithFriction(4, 0), + } + trends := ComputeTrends(scores) + frictionTrend := findTrend(trends, "friction") + if frictionTrend == nil { + t.Fatal("friction trend not found") + } + if frictionTrend.Direction != dirImproving { + t.Errorf("friction direction = %q, want improving (lower friction = better)", frictionTrend.Direction) + } +} + +func TestComputeTrends_DataPointsPopulated(t *testing.T) { + t.Parallel() + + scores := []SessionScore{ + makeScore(1, 60, 1000, 3, 0), + makeScore(2, 70, 1000, 3, 0), + makeScore(3, 80, 1000, 3, 0), + } + trends := ComputeTrends(scores) + overall := findTrend(trends, "overall_score") + if overall == nil { + t.Fatal("overall_score trend not found") + } + if len(overall.DataPoints) != 3 { + t.Errorf("DataPoints count = %d, want 3", len(overall.DataPoints)) + } +} + +func TestComputeTrends_AllMetricsPresent(t *testing.T) { + t.Parallel() + + scores := []SessionScore{makeScore(1, 70, 2000, 4, 0)} + trends := ComputeTrends(scores) + + expectedMetrics := []string{"overall_score", "token_usage", "friction", "turns_per_session"} + for _, metric := range expectedMetrics { + if findTrend(trends, metric) == nil { + t.Errorf("metric %q not found in trends", metric) + } + } +} + +func TestComputeAgentComparisons_Empty(t *testing.T) { + t.Parallel() + + comparisons := ComputeAgentComparisons(nil) + if len(comparisons) != 0 { + t.Errorf("expected 0 comparisons, got %d", len(comparisons)) + } +} + +func TestComputeAgentComparisons_SingleAgent(t *testing.T) { + t.Parallel() + + scores := []SessionScore{ + {Agent: "Claude Code", Overall: 80, TokensUsed: 2000, TurnCount: 4, FrictionCount: 1, + Breakdown: ScoreBreakdown{TokenEfficiency: 90, FirstPassSuccess: 70, FrictionScore: 80, FocusScore: 60}}, + {Agent: "Claude Code", Overall: 70, TokensUsed: 3000, TurnCount: 6, FrictionCount: 0, + Breakdown: ScoreBreakdown{TokenEfficiency: 80, FirstPassSuccess: 80, FrictionScore: 100, FocusScore: 70}}, + } + + comparisons := ComputeAgentComparisons(scores) + if len(comparisons) != 1 { + t.Fatalf("expected 1 comparison, got %d", len(comparisons)) + } + + c := comparisons[0] + if c.Agent != "Claude Code" { + t.Errorf("Agent = %q, want Claude Code", c.Agent) + } + if c.SessionCount != 2 { + t.Errorf("SessionCount = %d, want 2", c.SessionCount) + } + if c.AvgScore != 75.0 { + t.Errorf("AvgScore = %v, want 75.0", c.AvgScore) + } + if c.AvgTokens != 2500 { + t.Errorf("AvgTokens = %d, want 2500", c.AvgTokens) + } + if c.AvgTurns != 5.0 { + t.Errorf("AvgTurns = %v, want 5.0", c.AvgTurns) + } + if c.AvgFriction != 0.5 { + t.Errorf("AvgFriction = %v, want 0.5", c.AvgFriction) + } +} + +func TestComputeAgentComparisons_TopStrengthWeakness(t *testing.T) { + t.Parallel() + + scores := []SessionScore{ + { + Agent: "Claude Code", + Overall: 75, + Breakdown: ScoreBreakdown{ + TokenEfficiency: 90, // highest + FirstPassSuccess: 60, + FrictionScore: 40, // lowest + FocusScore: 70, + }, + }, + } + + comparisons := ComputeAgentComparisons(scores) + if len(comparisons) != 1 { + t.Fatalf("expected 1 comparison, got %d", len(comparisons)) + } + c := comparisons[0] + if c.TopStrength != "token_efficiency" { + t.Errorf("TopStrength = %q, want token_efficiency", c.TopStrength) + } + if c.TopWeakness != "friction_score" { + t.Errorf("TopWeakness = %q, want friction_score", c.TopWeakness) + } +} + +func TestComputeAgentComparisons_MultipleAgentsSortedByScore(t *testing.T) { + t.Parallel() + + scores := []SessionScore{ + {Agent: "Gemini CLI", Overall: 60, Breakdown: ScoreBreakdown{TokenEfficiency: 60, FirstPassSuccess: 60, FrictionScore: 60, FocusScore: 60}}, + {Agent: "Claude Code", Overall: 80, Breakdown: ScoreBreakdown{TokenEfficiency: 80, FirstPassSuccess: 80, FrictionScore: 80, FocusScore: 80}}, + {Agent: "OpenCode", Overall: 70, Breakdown: ScoreBreakdown{TokenEfficiency: 70, FirstPassSuccess: 70, FrictionScore: 70, FocusScore: 70}}, + } + + comparisons := ComputeAgentComparisons(scores) + if len(comparisons) != 3 { + t.Fatalf("expected 3 comparisons, got %d", len(comparisons)) + } + // Should be sorted by avg score descending + if comparisons[0].Agent != "Claude Code" { + t.Errorf("first agent = %q, want Claude Code", comparisons[0].Agent) + } + if comparisons[1].Agent != "OpenCode" { + t.Errorf("second agent = %q, want OpenCode", comparisons[1].Agent) + } + if comparisons[2].Agent != "Gemini CLI" { + t.Errorf("third agent = %q, want Gemini CLI", comparisons[2].Agent) + } +} + +// helpers + +func makeScore(dayOffset int, overall float64, tokens, turns, friction int) SessionScore { + return SessionScore{ + CreatedAt: time.Now().AddDate(0, 0, dayOffset), + Overall: overall, + TokensUsed: tokens, + TurnCount: turns, + FrictionCount: friction, + Agent: types.AgentType("Claude Code"), + Breakdown: ScoreBreakdown{TokenEfficiency: overall, FirstPassSuccess: overall, FrictionScore: overall, FocusScore: overall}, + } +} + +func makeScoreWithFriction(dayOffset int, friction int) SessionScore { + const fixedOverall = 60.0 + const fixedTurns = 5 + return SessionScore{ + CreatedAt: time.Now().AddDate(0, 0, dayOffset), + Overall: fixedOverall, + TokensUsed: 2000, + TurnCount: fixedTurns, + FrictionCount: friction, + Agent: types.AgentType("Claude Code"), + Breakdown: ScoreBreakdown{ + TokenEfficiency: fixedOverall, + FirstPassSuccess: fixedOverall, + FrictionScore: fixedOverall, + FocusScore: fixedOverall, + }, + } +} + +func findTrend(trends []Trend, metric string) *Trend { + for i := range trends { + if trends[i].Metric == metric { + return &trends[i] + } + } + return nil +} diff --git a/cmd/entire/cli/insights_cmd.go b/cmd/entire/cli/insights_cmd.go new file mode 100644 index 000000000..25e67b2d3 --- /dev/null +++ b/cmd/entire/cli/insights_cmd.go @@ -0,0 +1,544 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + checkpointid "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/insights" + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/summarize" + "github.com/entireio/cli/cmd/entire/cli/termstyle" + "github.com/go-git/go-git/v6/plumbing" + "github.com/spf13/cobra" +) + +func newInsightsCmd() *cobra.Command { + var last int + var agent string + var outputJSON bool + + cmd := &cobra.Command{ + Use: "insights", + Short: "Show session quality scores, trends, and agent comparisons", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + w := cmd.OutOrStdout() + + if checkDisabledGuard(ctx, w) { + return nil + } + + if !settings.IsSummarizeEnabled(ctx) { + fmt.Fprintln(w, "Summarization is required for insights. Enable it in .entire/settings.json:") + fmt.Fprintln(w, ` { "strategy_options": { "summarize": { "enabled": true } } }`) + return nil + } + + return runInsights(ctx, w, last, agent, outputJSON) + }, + } + + cmd.Flags().IntVar(&last, "last", 10, "number of recent sessions to analyze") + cmd.Flags().StringVar(&agent, "agent", "", "filter by agent name (e.g. \"Claude Code\")") + cmd.Flags().BoolVar(&outputJSON, "json", false, "output as JSON instead of styled terminal output") + + return cmd +} + +// runInsights fetches session data from the SQLite cache, refreshing it if stale, +// then computes quality scores and renders output. +func runInsights(ctx context.Context, w io.Writer, last int, agentFilter string, outputJSON bool) error { + worktreeRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("not in a git repository: %w", err) + } + entireDir := filepath.Join(worktreeRoot, paths.EntireDir) + + idb, err := insightsdb.Open(filepath.Join(entireDir, "insights.db")) + if err != nil { + return fmt.Errorf("open insights cache: %w", err) + } + defer func() { _ = idb.Close() }() + + // Non-fatal: continue with whatever is in the cache. + // If the cache is empty the command will show an empty report. + refreshCacheIfStale(ctx, idb) //nolint:errcheck,gosec // Non-fatal; continue with stale cache + + // Generate summaries for recent sessions that lack them. + backfillSummaries(ctx, w, idb, last) + backfillFacets(ctx, idb, last) + + var rows []insightsdb.SessionRow + if agentFilter != "" { + rows, err = idb.QueryByAgent(ctx, agentFilter, last) + } else { + rows, err = idb.QueryLastNSessions(ctx, last) + } + if err != nil { + return fmt.Errorf("query sessions: %w", err) + } + + scores := sessionRowsToScores(rows) + trends := insights.ComputeTrends(scores) + comparisons := insights.ComputeAgentComparisons(scores) + + period := fmt.Sprintf("last %d sessions", last) + if agentFilter != "" { + period = fmt.Sprintf("last %d sessions for %s", last, agentFilter) + } + + report := insights.Report{ + GeneratedAt: time.Now(), + Period: period, + Sessions: scores, + Trends: trends, + Comparisons: comparisons, + SessionCount: len(scores), + } + + if outputJSON { + return renderInsightsJSON(w, report) + } + renderInsightsTerminal(w, report) + return nil +} + +// refreshCacheIfStale checks whether the insights cache is up-to-date with the +// entire/checkpoints/v1 branch and rebuilds it if not. +func refreshCacheIfStale(ctx context.Context, idb *insightsdb.InsightsDB) error { + repo, err := openRepository(ctx) + if err != nil { + return fmt.Errorf("open git repository: %w", err) + } + + // Resolve the current tip of entire/checkpoints/v1. + refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + ref, resolveErr := repo.Reference(refName, true) + if resolveErr != nil { + // Branch doesn't exist yet — nothing to cache. + return nil //nolint:nilerr // Missing branch is expected, not an error + } + currentTip := ref.Hash().String() + + cachedTip, err := idb.GetBranchTip(ctx) + if err != nil { + return fmt.Errorf("get cached branch tip: %w", err) + } + + if cachedTip == currentTip { + return nil // Cache is up-to-date. + } + + // Cache is stale — rebuild from git. + store := checkpoint.NewGitStore(repo) + committedList, err := store.ListCommitted(ctx) + if err != nil { + return fmt.Errorf("list committed checkpoints: %w", err) + } + + for _, info := range committedList { + cpIDStr := info.CheckpointID.String() + + // Check whether we already have this checkpoint cached. + has, hasErr := idb.HasCheckpoint(ctx, cpIDStr) + if hasErr != nil { + return fmt.Errorf("check checkpoint %s: %w", cpIDStr, hasErr) + } + if has { + continue + } + + // Read the checkpoint summary to find how many sessions it has. + summary, readErr := store.ReadCommitted(ctx, info.CheckpointID) + if readErr != nil { + continue // Skip unreadable checkpoints; don't abort the whole refresh. + } + + for i := range summary.Sessions { + content, contentErr := store.ReadSessionContent(ctx, info.CheckpointID, i) + if contentErr != nil { + continue + } + row := metadataToSessionRow(cpIDStr, i, &content.Metadata) + row.ToolCounts = extractToolCounts(content.Transcript, content.Metadata.Agent) + if insertErr := idb.InsertSession(ctx, row); insertErr != nil { + return fmt.Errorf("insert session %s/%d: %w", cpIDStr, i, insertErr) + } + } + } + + if err := idb.SetBranchTip(ctx, currentTip); err != nil { + return fmt.Errorf("set branch tip: %w", err) + } + return nil +} + +// backfillSummaries generates summaries for the last N sessions that lack them. +// It reads transcripts from the checkpoint store, calls Claude to summarize, +// and updates the cache. Errors on individual sessions are skipped. +func backfillSummaries(ctx context.Context, w io.Writer, idb *insightsdb.InsightsDB, lastN int) { + rows, err := idb.QueryLastNSessions(ctx, lastN) + if err != nil { + return + } + + // Filter to sessions without summaries. + var unsummarized []insightsdb.SessionRow + for _, r := range rows { + if !r.HasSummary { + unsummarized = append(unsummarized, r) + } + } + if len(unsummarized) == 0 { + return + } + + repo, err := openRepository(ctx) + if err != nil { + return + } + store := checkpoint.NewGitStore(repo) + gen := &summarize.ClaudeGenerator{} + + s := termstyle.New(w) + fmt.Fprintf(w, "%s Generating summaries for %d sessions...\n", + s.Render(s.Dim, "i"), len(unsummarized)) + + generated := 0 + + for _, row := range unsummarized { + cpID, parseErr := checkpointid.NewCheckpointID(row.CheckpointID) + if parseErr != nil { + continue + } + + content, readErr := store.ReadSessionContent(ctx, cpID, row.SessionIndex) + if readErr != nil || len(content.Transcript) == 0 { + continue + } + + condensed, buildErr := summarize.BuildCondensedTranscriptFromBytes(content.Transcript, content.Metadata.Agent) + if buildErr != nil || len(condensed) == 0 { + continue + } + + input := summarize.Input{ + Transcript: condensed, + FilesTouched: row.FilesTouched, + } + summary, genErr := gen.Generate(ctx, input) + if genErr != nil || summary == nil { + continue + } + + // Rebuild the row with summary data. + content.Metadata.Summary = summary + updated := metadataToSessionRow(row.CheckpointID, row.SessionIndex, &content.Metadata) + + if updateErr := idb.UpdateSessionSummary(ctx, updated); updateErr != nil { + continue + } + + generated++ + fmt.Fprintf(w, " %s %s (%d/%d)\n", + s.Render(s.Green, "✓"), row.CheckpointID[:12], generated, len(unsummarized)) + } + + if generated > 0 { + fmt.Fprintf(w, " Generated %d summaries\n\n", generated) + } +} + +// extractToolCounts parses a transcript and counts tool invocations by name. +// Returns nil if the transcript can't be parsed. +func extractToolCounts(transcript []byte, agentType types.AgentType) map[string]int { + entries, err := summarize.BuildCondensedTranscriptFromBytes(transcript, agentType) + if err != nil || len(entries) == 0 { + return nil + } + counts := make(map[string]int) + for _, e := range entries { + if e.Type == summarize.EntryTypeTool && e.ToolName != "" { + counts[e.ToolName]++ + } + } + if len(counts) == 0 { + return nil + } + return counts +} + +// metadataToSessionRow converts CommittedMetadata into an insightsdb.SessionRow, +// computing quality scores where summary data is available. +func metadataToSessionRow(cpID string, sessionIndex int, meta *checkpoint.CommittedMetadata) insightsdb.SessionRow { + row := insightsdb.SessionRow{ + CheckpointID: cpID, + SessionID: meta.SessionID, + SessionIndex: sessionIndex, + Agent: string(meta.Agent), + Model: meta.Model, + Branch: meta.Branch, + OwnerName: meta.OwnerName, + OwnerEmail: meta.OwnerEmail, + CreatedAt: meta.CreatedAt, + } + + if meta.TokenUsage != nil { + row.InputTokens = meta.TokenUsage.InputTokens + meta.TokenUsage.CacheCreationTokens + meta.TokenUsage.CacheReadTokens + row.OutputTokens = meta.TokenUsage.OutputTokens + row.TotalTokens = termstyle.TotalTokens(meta.TokenUsage) + row.APICallCount = meta.TokenUsage.APICallCount + } + + if meta.SessionMetrics != nil { + row.DurationMs = meta.SessionMetrics.DurationMs + row.TurnCount = meta.SessionMetrics.TurnCount + } + + if meta.Summary != nil { + row.HasSummary = true + row.Intent = meta.Summary.Intent + row.Outcome = meta.Summary.Outcome + row.Friction = meta.Summary.Friction + + for _, l := range meta.Summary.Learnings.Repo { + row.Learnings = append(row.Learnings, insightsdb.LearningRow{Scope: "repo", Finding: l}) + } + for _, l := range meta.Summary.Learnings.Workflow { + row.Learnings = append(row.Learnings, insightsdb.LearningRow{Scope: "workflow", Finding: l}) + } + for _, l := range meta.Summary.Learnings.Code { + row.Learnings = append(row.Learnings, insightsdb.LearningRow{Scope: "code", Finding: l.Finding, Path: l.Path}) + } + } + + // Always compute scores — token efficiency and focus work without summaries. + // Friction/first-pass default to neutral when no summary exists. + data := insights.SessionData{ + TotalTokens: row.TotalTokens, + FilesCount: len(meta.FilesTouched), + FrictionCount: len(row.Friction), + TurnCount: row.TurnCount, + HasSummary: row.HasSummary, + } + if meta.Summary != nil { + data.OpenItemCount = len(meta.Summary.OpenItems) + } + breakdown := insights.ScoreSession(data) + row.OverallScore = insights.ComputeOverall(breakdown) + row.ScoreTokenEff = breakdown.TokenEfficiency + row.ScoreFirstPass = breakdown.FirstPassSuccess + row.ScoreFriction = breakdown.FrictionScore + row.ScoreFocus = breakdown.FocusScore + + row.FilesTouched = meta.FilesTouched + return row +} + +// sessionRowsToScores converts database rows into insights.SessionScore values. +func sessionRowsToScores(rows []insightsdb.SessionRow) []insights.SessionScore { + scores := make([]insights.SessionScore, 0, len(rows)) + for _, r := range rows { + scores = append(scores, insights.SessionScore{ + CheckpointID: r.CheckpointID, + SessionID: r.SessionID, + Agent: types.AgentType(r.Agent), + Model: r.Model, + CreatedAt: r.CreatedAt, + Overall: r.OverallScore, + Breakdown: insights.ScoreBreakdown{ + TokenEfficiency: r.ScoreTokenEff, + FirstPassSuccess: r.ScoreFirstPass, + FrictionScore: r.ScoreFriction, + FocusScore: r.ScoreFocus, + }, + TokensUsed: r.TotalTokens, + TurnCount: r.TurnCount, + FilesCount: len(r.FilesTouched), + FrictionCount: len(r.Friction), + HasSummary: r.HasSummary, + ToolCallCount: totalToolCalls(r.ToolCounts), + TopTools: topToolNames(r.ToolCounts, 3), + SkillsUsed: skillNames(r.ToolCounts), + }) + } + return scores +} + +func totalToolCalls(counts map[string]int) int { + total := 0 + for _, c := range counts { + total += c + } + return total +} + +func topToolNames(counts map[string]int, n int) []string { + if len(counts) == 0 { + return nil + } + type kv struct { + name string + count int + } + sorted := make([]kv, 0, len(counts)) + for k, v := range counts { + if k == "Skill" { + continue // skills shown separately + } + sorted = append(sorted, kv{k, v}) + } + // Simple selection sort for small N. + for i := range min(n, len(sorted)) { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].count > sorted[i].count { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + limit := min(n, len(sorted)) + names := make([]string, limit) + for i := range limit { + names[i] = sorted[i].name + } + return names +} + +func skillNames(counts map[string]int) []string { + // Skill tool calls are counted under "Skill" in tool counts. + // We can't distinguish individual skill names from counts alone, + // so we just indicate whether skills were used. + if counts["Skill"] > 0 { + return []string{fmt.Sprintf("%d invocations", counts["Skill"])} + } + return nil +} + +// renderInsightsJSON marshals the report to JSON and writes it to w. +func renderInsightsJSON(w io.Writer, report insights.Report) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return fmt.Errorf("marshal insights report: %w", err) + } + return nil +} + +// renderInsightsTerminal writes a styled terminal view of the insights report. +func renderInsightsTerminal(w io.Writer, report insights.Report) { + s := termstyle.New(w) + + fmt.Fprintln(w, s.Render(s.Bold, "Entire Insights")) + fmt.Fprintf(w, "Period: %s\n\n", report.Period) + + // Session Scores section. + fmt.Fprintln(w, s.SectionRule("Session Scores")) + if len(report.Sessions) == 0 { + fmt.Fprintln(w, " No sessions found.") + } + for _, ss := range report.Sessions { + shortID := ss.SessionID + if len(shortID) > 12 { + shortID = shortID[:12] + } + scoreLine := fmt.Sprintf(" %5.1f %s %s", + ss.Overall, + string(ss.Agent), + shortID, + ) + fmt.Fprintln(w, s.Render(s.Bold, scoreLine)) + + breakdownLine := fmt.Sprintf(" Token Efficiency: %.0f First-Pass: %.0f Friction: %.0f Focus: %.0f", + ss.Breakdown.TokenEfficiency, + ss.Breakdown.FirstPassSuccess, + ss.Breakdown.FrictionScore, + ss.Breakdown.FocusScore, + ) + fmt.Fprintln(w, s.Render(s.Dim, breakdownLine)) + + statsLine := fmt.Sprintf(" %s tokens %d turns %d files %d friction", + termstyle.FormatTokenCount(ss.TokensUsed), + ss.TurnCount, + ss.FilesCount, + ss.FrictionCount, + ) + if !ss.HasSummary { + statsLine += " (no summary)" + } + fmt.Fprintln(w, s.Render(s.Gray, statsLine)) + + if len(ss.TopTools) > 0 { + toolsLine := " Tools: " + strings.Join(ss.TopTools, ", ") + if len(ss.SkillsUsed) > 0 { + toolsLine += " Skills: " + strings.Join(ss.SkillsUsed, ", ") + } + fmt.Fprintln(w, s.Render(s.Gray, toolsLine)) + } + fmt.Fprintln(w) + } + + // Trends section. + fmt.Fprintln(w, s.SectionRule("Trends")) + for _, t := range report.Trends { + arrow := "→" + style := s.Gray + dirLabel := "stable" + switch t.Direction { + case "improving": + arrow = "↑" + style = s.Green + dirLabel = fmt.Sprintf("+%.1f%%", t.ChangePercent) + case "declining": + arrow = "↓" + style = s.Red + dirLabel = fmt.Sprintf("-%.1f%%", t.ChangePercent) + } + line := fmt.Sprintf(" %s %s (%s)", arrow, t.Metric, dirLabel) + fmt.Fprintln(w, s.Render(style, line)) + } + fmt.Fprintln(w) + + // Agent Comparison section. + fmt.Fprintln(w, s.SectionRule("Agent Comparison")) + if len(report.Comparisons) == 0 { + fmt.Fprintln(w, " Not enough data for comparison.") + } + for _, ac := range report.Comparisons { + headerLine := fmt.Sprintf(" %5.1f %s (%d sessions)", + ac.AvgScore, + string(ac.Agent), + ac.SessionCount, + ) + fmt.Fprintln(w, s.Render(s.Bold, headerLine)) + + statsLine := fmt.Sprintf(" avg %s tokens %.1f turns %.1f friction", + termstyle.FormatTokenCount(ac.AvgTokens), + ac.AvgTurns, + ac.AvgFriction, + ) + fmt.Fprintln(w, s.Render(s.Gray, statsLine)) + + if len(ac.TopTools) > 0 { + toolLine := fmt.Sprintf(" Top tools: %s | Avg %.0f tool calls/session", + strings.Join(ac.TopTools, ", "), ac.AvgToolCalls) + fmt.Fprintln(w, s.Render(s.Gray, toolLine)) + } + if ac.TopStrength != "" { + fmt.Fprintln(w, s.Render(s.Green, " + "+ac.TopStrength)) + } + if ac.TopWeakness != "" { + fmt.Fprintln(w, s.Render(s.Red, " - "+ac.TopWeakness)) + } + fmt.Fprintln(w) + } +} diff --git a/cmd/entire/cli/insightsdb/cache.go b/cmd/entire/cli/insightsdb/cache.go new file mode 100644 index 000000000..736af8490 --- /dev/null +++ b/cmd/entire/cli/insightsdb/cache.go @@ -0,0 +1,394 @@ +package insightsdb + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/facets" +) + +// SessionRow represents a single session for insertion into the cache. +// This is the denormalized view used by the CLI — callers populate it +// from checkpoint.CommittedMetadata. +type SessionRow struct { + CheckpointID string + SessionID string + SessionIndex int + Agent string + Model string + Branch string + OwnerName string + OwnerEmail string + CreatedAt time.Time + InputTokens int + CacheTokens int + OutputTokens int + TotalTokens int + APICallCount int + DurationMs int64 + TurnCount int + Intent string + Outcome string + AgentPct float64 + // Score fields + OverallScore float64 + ScoreTokenEff float64 + ScoreFirstPass float64 + ScoreFriction float64 + ScoreFocus float64 + HasSummary bool + HasFacets bool + // Denormalized arrays + FilesTouched []string + Friction []string + Learnings []LearningRow + ToolCounts map[string]int // tool name → invocation count + Facets facets.SessionFacets +} + +// LearningRow represents a single learning entry within a session. +type LearningRow struct { + Scope string // "repo", "workflow", "code" + Finding string + Path string // only meaningful when Scope is "code" +} + +// GetBranchTip returns the stored branch tip hash from cache_meta, +// or an empty string if it has not been set yet. +func (idb *InsightsDB) GetBranchTip(ctx context.Context) (string, error) { + var tip string + err := idb.db.QueryRowContext(ctx, + "SELECT value FROM cache_meta WHERE key = ?", + "branch_tip", + ).Scan(&tip) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get branch tip: %w", err) + } + return tip, nil +} + +// SetBranchTip stores the branch tip hash in cache_meta. +// Overwrites any previously stored value. +func (idb *InsightsDB) SetBranchTip(ctx context.Context, tip string) error { + _, err := idb.db.ExecContext(ctx, + "INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)", + "branch_tip", + tip, + ) + if err != nil { + return fmt.Errorf("set branch tip: %w", err) + } + return nil +} + +// HasCheckpoint returns true if any session for the given checkpoint ID +// is already present in the sessions table. +func (idb *InsightsDB) HasCheckpoint(ctx context.Context, checkpointID string) (bool, error) { + var count int + err := idb.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM sessions WHERE checkpoint_id = ?", + checkpointID, + ).Scan(&count) + if err != nil { + return false, fmt.Errorf("check checkpoint existence: %w", err) + } + return count > 0, nil +} + +// InsertSession inserts a session and all its denormalized data into the cache. +// The insert is performed inside a single transaction so the cache remains +// consistent even if the caller is interrupted mid-insert. +func (idb *InsightsDB) InsertSession(ctx context.Context, row SessionRow) error { + tx, err := idb.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() //nolint:errcheck // Rollback after failed tx; error is irrelevant + } + }() + + if err = insertSessionRow(ctx, tx, row); err != nil { + return err + } + if err = insertFilesTouched(ctx, tx, row); err != nil { + return err + } + if err = insertFriction(ctx, tx, row); err != nil { + return err + } + if err = insertLearnings(ctx, tx, row); err != nil { + return err + } + if err = insertToolCalls(ctx, tx, row); err != nil { + return err + } + if err = insertFacets(ctx, tx, row); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +func insertSessionRow(ctx context.Context, tx *sql.Tx, row SessionRow) error { + hasSummary := 0 + if row.HasSummary { + hasSummary = 1 + } + hasFacets := 0 + if row.HasFacets || !isEmptyFacets(row.Facets) { + hasFacets = 1 + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO sessions ( + checkpoint_id, session_id, session_index, + agent, model, branch, owner_name, owner_email, created_at, + input_tokens, cache_tokens, output_tokens, total_tokens, + api_call_count, duration_ms, turn_count, + intent, outcome, agent_percentage, + overall_score, score_token_efficiency, score_first_pass, + score_friction, score_focus, has_summary, has_facets + ) VALUES ( + ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ? + )`, + row.CheckpointID, row.SessionID, row.SessionIndex, + nullableString(row.Agent), nullableString(row.Model), nullableString(row.Branch), + nullableString(row.OwnerName), nullableString(row.OwnerEmail), + row.CreatedAt.UTC().Format(time.RFC3339), + row.InputTokens, row.CacheTokens, row.OutputTokens, row.TotalTokens, + row.APICallCount, row.DurationMs, row.TurnCount, + nullableString(row.Intent), nullableString(row.Outcome), row.AgentPct, + row.OverallScore, row.ScoreTokenEff, + row.ScoreFirstPass, row.ScoreFriction, + row.ScoreFocus, hasSummary, hasFacets, + ) + if err != nil { + return fmt.Errorf("insert session row: %w", err) + } + return nil +} + +func insertFilesTouched(ctx context.Context, tx *sql.Tx, row SessionRow) error { + for _, f := range row.FilesTouched { + if _, err := tx.ExecContext(ctx, + "INSERT INTO files_touched (checkpoint_id, session_index, file_path) VALUES (?, ?, ?)", + row.CheckpointID, row.SessionIndex, f, + ); err != nil { + return fmt.Errorf("insert files_touched: %w", err) + } + } + return nil +} + +func insertFriction(ctx context.Context, tx *sql.Tx, row SessionRow) error { + for _, f := range row.Friction { + if _, err := tx.ExecContext(ctx, + "INSERT INTO friction (checkpoint_id, session_index, text) VALUES (?, ?, ?)", + row.CheckpointID, row.SessionIndex, f, + ); err != nil { + return fmt.Errorf("insert friction: %w", err) + } + } + return nil +} + +func insertLearnings(ctx context.Context, tx *sql.Tx, row SessionRow) error { + for _, l := range row.Learnings { + if _, err := tx.ExecContext(ctx, + "INSERT INTO learnings (checkpoint_id, session_index, scope, finding, path) VALUES (?, ?, ?, ?, ?)", + row.CheckpointID, row.SessionIndex, l.Scope, l.Finding, nullableString(l.Path), + ); err != nil { + return fmt.Errorf("insert learnings: %w", err) + } + } + return nil +} + +func insertToolCalls(ctx context.Context, tx *sql.Tx, row SessionRow) error { + for tool, count := range row.ToolCounts { + if _, err := tx.ExecContext(ctx, + "INSERT INTO tool_calls (checkpoint_id, session_index, tool_name, count) VALUES (?, ?, ?, ?)", + row.CheckpointID, row.SessionIndex, tool, count, + ); err != nil { + return fmt.Errorf("insert tool_calls: %w", err) + } + } + return nil +} + +func insertFacets(ctx context.Context, tx *sql.Tx, row SessionRow) error { + for _, instruction := range row.Facets.RepeatedUserInstructions { + if _, err := tx.ExecContext(ctx, + `INSERT INTO repeated_user_instructions (checkpoint_id, session_index, instruction, evidence) + VALUES (?, ?, ?, ?)`, + row.CheckpointID, row.SessionIndex, instruction.Instruction, joinEvidence(instruction.Evidence), + ); err != nil { + return fmt.Errorf("insert repeated_user_instructions: %w", err) + } + } + for _, signal := range row.Facets.MissingContext { + if _, err := tx.ExecContext(ctx, + `INSERT INTO missing_context_signals (checkpoint_id, session_index, item, evidence) + VALUES (?, ?, ?, ?)`, + row.CheckpointID, row.SessionIndex, signal.Item, joinEvidence(signal.Evidence), + ); err != nil { + return fmt.Errorf("insert missing_context_signals: %w", err) + } + } + for _, loop := range row.Facets.FailureLoops { + if _, err := tx.ExecContext(ctx, + `INSERT INTO failure_loops (checkpoint_id, session_index, description, count, evidence) + VALUES (?, ?, ?, ?, ?)`, + row.CheckpointID, row.SessionIndex, loop.Description, loop.Count, joinEvidence(loop.Evidence), + ); err != nil { + return fmt.Errorf("insert failure_loops: %w", err) + } + } + for _, signal := range row.Facets.SkillSignals { + if _, err := tx.ExecContext(ctx, + `INSERT INTO skill_signals (checkpoint_id, session_index, skill_name, skill_path, friction, missing_instruction) + VALUES (?, ?, ?, ?, ?, ?)`, + row.CheckpointID, row.SessionIndex, signal.SkillName, nullableString(signal.SkillPath), + joinEvidence(signal.Friction), nullableString(signal.MissingInstruction), + ); err != nil { + return fmt.Errorf("insert skill_signals: %w", err) + } + } + return nil +} + +// UpdateSessionSummary updates an existing session row with summary-derived data +// (intent, outcome, scores, friction, learnings) and sets has_summary = 1. +func (idb *InsightsDB) UpdateSessionSummary(ctx context.Context, row SessionRow) error { + tx, err := idb.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() //nolint:errcheck // Rollback after failed tx; error is irrelevant + } + }() + + _, err = tx.ExecContext(ctx, ` + UPDATE sessions SET + intent = ?, outcome = ?, + overall_score = ?, score_token_efficiency = ?, score_first_pass = ?, + score_friction = ?, score_focus = ?, has_summary = 1 + WHERE checkpoint_id = ? AND session_index = ?`, + nullableString(row.Intent), nullableString(row.Outcome), + row.OverallScore, row.ScoreTokenEff, row.ScoreFirstPass, + row.ScoreFriction, row.ScoreFocus, + row.CheckpointID, row.SessionIndex, + ) + if err != nil { + return fmt.Errorf("update session summary: %w", err) + } + + // Delete old friction/learnings and re-insert. + for _, table := range []string{"friction", "learnings"} { + if _, err = tx.ExecContext(ctx, + "DELETE FROM "+table+" WHERE checkpoint_id = ? AND session_index = ?", //nolint:gosec // table name is hardcoded + row.CheckpointID, row.SessionIndex, + ); err != nil { + return fmt.Errorf("delete old %s: %w", table, err) + } + } + + if err = insertFriction(ctx, tx, row); err != nil { + return err + } + if err = insertLearnings(ctx, tx, row); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +// UpdateSessionFacets updates an existing session row with extracted structured facets. +func (idb *InsightsDB) UpdateSessionFacets(ctx context.Context, row SessionRow) error { + tx, err := idb.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() //nolint:errcheck // Rollback after failed tx; error is irrelevant + } + }() + + if _, err = tx.ExecContext(ctx, ` + UPDATE sessions SET has_facets = 1 + WHERE checkpoint_id = ? AND session_index = ?`, + row.CheckpointID, row.SessionIndex, + ); err != nil { + return fmt.Errorf("update session facets: %w", err) + } + + for _, table := range []string{ + "repeated_user_instructions", + "missing_context_signals", + "failure_loops", + "skill_signals", + } { + if _, err = tx.ExecContext(ctx, + "DELETE FROM "+table+" WHERE checkpoint_id = ? AND session_index = ?", //nolint:gosec // table name is hardcoded + row.CheckpointID, row.SessionIndex, + ); err != nil { + return fmt.Errorf("delete old %s: %w", table, err) + } + } + + if err = insertFacets(ctx, tx, row); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +// nullableString converts an empty string to a SQL NULL value. +// Non-empty strings are passed through as-is. +func nullableString(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func joinEvidence(values []string) interface{} { + if len(values) == 0 { + return nil + } + return strings.Join(values, "\n") +} + +func isEmptyFacets(v facets.SessionFacets) bool { + return len(v.RepeatedUserInstructions) == 0 && + len(v.MissingContext) == 0 && + len(v.FailureLoops) == 0 && + len(v.SkillSignals) == 0 && + len(v.RepoGotchas) == 0 && + len(v.WorkflowGaps) == 0 +} diff --git a/cmd/entire/cli/insightsdb/cache_test.go b/cmd/entire/cli/insightsdb/cache_test.go new file mode 100644 index 000000000..065964dfd --- /dev/null +++ b/cmd/entire/cli/insightsdb/cache_test.go @@ -0,0 +1,282 @@ +package insightsdb_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/facets" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/insightsdb" +) + +func openTestDB(t *testing.T) *insightsdb.InsightsDB { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "insights.db") + db, err := insightsdb.Open(dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestGetBranchTip_EmptyWhenNotSet(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + tip, err := db.GetBranchTip(ctx) + require.NoError(t, err) + assert.Empty(t, tip) +} + +func TestSetAndGetBranchTip(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + err := db.SetBranchTip(ctx, "abc123def456") + require.NoError(t, err) + + tip, err := db.GetBranchTip(ctx) + require.NoError(t, err) + assert.Equal(t, "abc123def456", tip) +} + +func TestSetBranchTip_Overwrites(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + require.NoError(t, db.SetBranchTip(ctx, "first")) + require.NoError(t, db.SetBranchTip(ctx, "second")) + + tip, err := db.GetBranchTip(ctx) + require.NoError(t, err) + assert.Equal(t, "second", tip) +} + +func TestHasCheckpoint_FalseWhenAbsent(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + has, err := db.HasCheckpoint(ctx, "nonexistent") + require.NoError(t, err) + assert.False(t, has) +} + +func TestHasCheckpoint_TrueAfterInsert(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row := minimalSessionRow("chk-001", "sess-001", 0) + require.NoError(t, db.InsertSession(ctx, row)) + + has, err := db.HasCheckpoint(ctx, "chk-001") + require.NoError(t, err) + assert.True(t, has) +} + +func TestInsertSession_BasicFields(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row := insightsdb.SessionRow{ + CheckpointID: "chk-basic", + SessionID: "sess-basic", + SessionIndex: 0, + Agent: "claude-code", + Model: "claude-3-5-sonnet", + Branch: "main", + OwnerName: "Test User", + OwnerEmail: "test@example.com", + CreatedAt: time.Date(2026, 3, 24, 10, 0, 0, 0, time.UTC), + InputTokens: 1000, + CacheTokens: 200, + OutputTokens: 300, + TotalTokens: 1500, + APICallCount: 5, + DurationMs: 30000, + TurnCount: 3, + Intent: "fix bug", + Outcome: "success", + AgentPct: 0.85, + } + + err := db.InsertSession(ctx, row) + require.NoError(t, err) + + sessions, err := db.QueryLastNSessions(ctx, 10) + require.NoError(t, err) + require.Len(t, sessions, 1) + + got := sessions[0] + assert.Equal(t, "chk-basic", got.CheckpointID) + assert.Equal(t, "sess-basic", got.SessionID) + assert.Equal(t, 0, got.SessionIndex) + assert.Equal(t, "claude-code", got.Agent) + assert.Equal(t, "claude-3-5-sonnet", got.Model) + assert.Equal(t, "main", got.Branch) + assert.Equal(t, "Test User", got.OwnerName) + assert.Equal(t, "test@example.com", got.OwnerEmail) + assert.Equal(t, 1000, got.InputTokens) + assert.Equal(t, 200, got.CacheTokens) + assert.Equal(t, 300, got.OutputTokens) + assert.Equal(t, 1500, got.TotalTokens) + assert.Equal(t, 5, got.APICallCount) + assert.Equal(t, int64(30000), got.DurationMs) + assert.Equal(t, 3, got.TurnCount) + assert.Equal(t, "fix bug", got.Intent) + assert.Equal(t, "success", got.Outcome) + assert.InDelta(t, 0.85, got.AgentPct, 0.001) +} + +func TestInsertSession_WithDenormalizedFields(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row := insightsdb.SessionRow{ + CheckpointID: "chk-denorm", + SessionID: "sess-denorm", + SessionIndex: 0, + CreatedAt: time.Now(), + FilesTouched: []string{"main.go", "util.go", "README.md"}, + Friction: []string{"had to retry", "tool failed twice"}, + Learnings: []insightsdb.LearningRow{ + {Scope: "repo", Finding: "uses conventional commits"}, + {Scope: "code", Finding: "helpers in util.go", Path: "util.go"}, + }, + } + + err := db.InsertSession(ctx, row) + require.NoError(t, err) + + sessions, err := db.QueryLastNSessions(ctx, 10) + require.NoError(t, err) + require.Len(t, sessions, 1) + + got := sessions[0] + assert.ElementsMatch(t, []string{"main.go", "util.go", "README.md"}, got.FilesTouched) + assert.ElementsMatch(t, []string{"had to retry", "tool failed twice"}, got.Friction) + require.Len(t, got.Learnings, 2) +} + +func TestInsertSession_WithStructuredFacets(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row := insightsdb.SessionRow{ + CheckpointID: "chk-facets", + SessionID: "sess-facets", + SessionIndex: 0, + CreatedAt: time.Now(), + Facets: facets.SessionFacets{ + RepeatedUserInstructions: []facets.RepeatedInstruction{ + {Instruction: "Run golangci-lint before committing", Evidence: []string{"User repeated lint guidance"}}, + }, + MissingContext: []facets.MissingContextSignal{ + {Item: "Repo has nolint formatting gotcha", Evidence: []string{"Agent missed repo rule"}}, + }, + FailureLoops: []facets.FailureLoop{ + {Description: "Lint issue reappeared after fmt", Count: 2, Evidence: []string{"Returned after formatting"}}, + }, + SkillSignals: []facets.SkillSignal{ + { + SkillName: "project:go-linting", + SkillPath: ".codex/skills/go-linting/SKILL.md", + Friction: []string{"Skill missed gofmt/nolint interaction"}, + MissingInstruction: "Warn about trailing nolint comments on signatures", + }, + }, + }, + } + + err := db.InsertSession(ctx, row) + require.NoError(t, err) + + sessions, err := db.QueryLastNSessions(ctx, 10) + require.NoError(t, err) + require.Len(t, sessions, 1) + + got := sessions[0] + assert.True(t, got.HasFacets) + require.Len(t, got.Facets.RepeatedUserInstructions, 1) + assert.Equal(t, "Run golangci-lint before committing", got.Facets.RepeatedUserInstructions[0].Instruction) + require.Len(t, got.Facets.SkillSignals, 1) + assert.Equal(t, "project:go-linting", got.Facets.SkillSignals[0].SkillName) +} + +func TestInsertSession_ScoreFields(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row := insightsdb.SessionRow{ + CheckpointID: "chk-scores", + SessionID: "sess-scores", + SessionIndex: 0, + CreatedAt: time.Now(), + OverallScore: 0.75, + ScoreTokenEff: 0.80, + ScoreFirstPass: 0.70, + ScoreFriction: 0.65, + ScoreFocus: 0.90, + } + + err := db.InsertSession(ctx, row) + require.NoError(t, err) + + sessions, err := db.QueryLastNSessions(ctx, 10) + require.NoError(t, err) + require.Len(t, sessions, 1) + + got := sessions[0] + assert.InDelta(t, 0.75, got.OverallScore, 0.001) + assert.InDelta(t, 0.80, got.ScoreTokenEff, 0.001) + assert.InDelta(t, 0.70, got.ScoreFirstPass, 0.001) + assert.InDelta(t, 0.65, got.ScoreFriction, 0.001) + assert.InDelta(t, 0.90, got.ScoreFocus, 0.001) +} + +func TestInsertSession_MultipleSessionsSameCheckpoint(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + for i := range 3 { + row := minimalSessionRow("chk-multi", "sess-multi-"+string(rune('A'+i)), i) + require.NoError(t, db.InsertSession(ctx, row)) + } + + sessions, err := db.QueryLastNSessions(ctx, 10) + require.NoError(t, err) + assert.Len(t, sessions, 3) +} + +// minimalSessionRow creates a valid SessionRow with required fields only. +func minimalSessionRow(checkpointID, sessionID string, index int) insightsdb.SessionRow { + return insightsdb.SessionRow{ + CheckpointID: checkpointID, + SessionID: sessionID, + SessionIndex: index, + CreatedAt: time.Now(), + } +} diff --git a/cmd/entire/cli/insightsdb/db.go b/cmd/entire/cli/insightsdb/db.go new file mode 100644 index 000000000..57e2ae8c6 --- /dev/null +++ b/cmd/entire/cli/insightsdb/db.go @@ -0,0 +1,218 @@ +// Package insightsdb provides a SQLite cache layer for session analytics. +// It stores session metadata for fast querying by the insights and improve commands. +package insightsdb + +import ( + "context" + "database/sql" + "fmt" + "strings" + + _ "modernc.org/sqlite" // SQLite driver +) + +// InsightsDB wraps a SQLite database for insights caching. +type InsightsDB struct { + db *sql.DB +} + +// Open opens (or creates) the insights cache at the given path. +// It sets WAL mode and busy timeout for safe concurrent access, +// then runs migrations to ensure all tables exist. +func Open(dbPath string) (*InsightsDB, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open sqlite database: %w", err) + } + + if err = applyPragmas(db); err != nil { + _ = db.Close() + return nil, err + } + + idb := &InsightsDB{db: db} + if err = idb.migrate(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("run migrations: %w", err) + } + + return idb, nil +} + +// applyPragmas sets performance and safety pragmas on the database. +func applyPragmas(db *sql.DB) error { + ctx := context.Background() + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA busy_timeout=5000", + } + for _, pragma := range pragmas { + if _, err := db.ExecContext(ctx, pragma); err != nil { + return fmt.Errorf("apply pragma %q: %w", pragma, err) + } + } + return nil +} + +// Close closes the underlying database connection. +func (idb *InsightsDB) Close() error { + if err := idb.db.Close(); err != nil { + return fmt.Errorf("close insights database: %w", err) + } + return nil +} + +// migrate creates all tables if they do not already exist. +// It is safe to call multiple times (idempotent). +func (idb *InsightsDB) migrate() error { + ctx := context.Background() + statements := []string{ + `CREATE TABLE IF NOT EXISTS cache_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + checkpoint_id TEXT NOT NULL, + session_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + agent TEXT, + model TEXT, + branch TEXT, + owner_name TEXT, + owner_email TEXT, + created_at TEXT NOT NULL, + input_tokens INTEGER DEFAULT 0, + cache_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + api_call_count INTEGER DEFAULT 0, + duration_ms INTEGER DEFAULT 0, + turn_count INTEGER DEFAULT 0, + intent TEXT, + outcome TEXT, + agent_percentage REAL DEFAULT 0, + overall_score REAL, + score_token_efficiency REAL, + score_first_pass REAL, + score_friction REAL, + score_focus REAL, + has_summary INTEGER DEFAULT 0, + has_facets INTEGER DEFAULT 0, + PRIMARY KEY (checkpoint_id, session_index) + )`, + `CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`, + `CREATE TABLE IF NOT EXISTS files_touched ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + file_path TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS friction ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + text TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_friction_text ON friction(text)`, + `CREATE TABLE IF NOT EXISTS learnings ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + scope TEXT NOT NULL, + finding TEXT NOT NULL, + path TEXT + )`, + `CREATE TABLE IF NOT EXISTS tool_calls ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + tool_name TEXT NOT NULL, + count INTEGER DEFAULT 1 + )`, + `CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(checkpoint_id, session_index)`, + `CREATE TABLE IF NOT EXISTS repeated_user_instructions ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + instruction TEXT NOT NULL, + evidence TEXT + )`, + `CREATE TABLE IF NOT EXISTS missing_context_signals ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + item TEXT NOT NULL, + evidence TEXT + )`, + `CREATE TABLE IF NOT EXISTS failure_loops ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + description TEXT NOT NULL, + count INTEGER DEFAULT 1, + evidence TEXT + )`, + `CREATE TABLE IF NOT EXISTS skill_signals ( + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + skill_name TEXT NOT NULL, + skill_path TEXT, + friction TEXT, + missing_instruction TEXT + )`, + `CREATE TABLE IF NOT EXISTS suggestions ( + id TEXT PRIMARY KEY, + file_type TEXT NOT NULL, + category TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + diff TEXT, + priority TEXT DEFAULT 'medium', + status TEXT DEFAULT 'pending', + created_at TEXT NOT NULL, + resolved_at TEXT + )`, + } + + for _, stmt := range statements { + if _, err := idb.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("execute migration statement: %w", err) + } + } + for _, alter := range []string{ + `ALTER TABLE sessions ADD COLUMN owner_name TEXT`, + `ALTER TABLE sessions ADD COLUMN owner_email TEXT`, + } { + if _, err := idb.db.ExecContext(ctx, alter); err != nil && !isDuplicateColumnError(err) { + return fmt.Errorf("execute migration statement: %w", err) + } + } + return nil +} + +func isDuplicateColumnError(err error) bool { + return err != nil && (contains(err.Error(), "duplicate column name") || contains(err.Error(), "already exists")) +} + +func contains(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + +// ListTables returns the names of all user tables in the database. +// This is used in tests to verify migrations ran correctly. +func (idb *InsightsDB) ListTables(ctx context.Context) ([]string, error) { + rows, err := idb.db.QueryContext(ctx, + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", + ) + if err != nil { + return nil, fmt.Errorf("query tables: %w", err) + } + defer rows.Close() + + var tables []string + for rows.Next() { + var name string + if err = rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan table name: %w", err) + } + tables = append(tables, name) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate tables: %w", err) + } + return tables, nil +} diff --git a/cmd/entire/cli/insightsdb/db_test.go b/cmd/entire/cli/insightsdb/db_test.go new file mode 100644 index 000000000..6a37960bd --- /dev/null +++ b/cmd/entire/cli/insightsdb/db_test.go @@ -0,0 +1,85 @@ +package insightsdb_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/insightsdb" +) + +func TestOpen_CreatesDatabase(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "insights.db") + + db, err := insightsdb.Open(dbPath) + require.NoError(t, err) + require.NotNil(t, db) + + t.Cleanup(func() { + assert.NoError(t, db.Close()) + }) +} + +func TestOpen_CreatesAllTables(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "insights.db") + + db, err := insightsdb.Open(dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + ctx := context.Background() + + // Verify all expected tables exist by querying sqlite_master + tables, err := db.ListTables(ctx) + require.NoError(t, err) + + expected := []string{ + "cache_meta", + "sessions", + "files_touched", + "friction", + "learnings", + "suggestions", + } + for _, table := range expected { + assert.Contains(t, tables, table, "expected table %q to exist", table) + } +} + +func TestOpen_Idempotent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "insights.db") + + // Open twice — migrations should be idempotent (CREATE TABLE IF NOT EXISTS) + db1, err := insightsdb.Open(dbPath) + require.NoError(t, err) + require.NoError(t, db1.Close()) + + db2, err := insightsdb.Open(dbPath) + require.NoError(t, err) + require.NoError(t, db2.Close()) +} + +func TestClose_CanBeCalledOnce(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "insights.db") + + db, err := insightsdb.Open(dbPath) + require.NoError(t, err) + + err = db.Close() + assert.NoError(t, err) +} diff --git a/cmd/entire/cli/insightsdb/queries.go b/cmd/entire/cli/insightsdb/queries.go new file mode 100644 index 000000000..47ba768d9 --- /dev/null +++ b/cmd/entire/cli/insightsdb/queries.go @@ -0,0 +1,621 @@ +package insightsdb + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/facets" +) + +// FrictionTheme groups recurring friction entries by their text content. +type FrictionTheme struct { + Text string `json:"text"` + Count int `json:"count"` + Sessions []string `json:"sessions"` // checkpoint IDs where this friction occurred +} + +// QueryLastNSessions returns the most recent N sessions ordered by created_at DESC. +// Denormalized fields (FilesTouched, Friction, Learnings) are populated. +func (idb *InsightsDB) QueryLastNSessions(ctx context.Context, n int) ([]SessionRow, error) { + return idb.querySessions(ctx, + "SELECT "+sessionColumns+" FROM sessions ORDER BY created_at DESC LIMIT ?", + n, + ) +} + +// QueryByAgent returns sessions filtered by agent name, most recent first. +func (idb *InsightsDB) QueryByAgent(ctx context.Context, agent string, limit int) ([]SessionRow, error) { + return idb.querySessions(ctx, + "SELECT "+sessionColumns+" FROM sessions WHERE agent = ? ORDER BY created_at DESC LIMIT ?", + agent, limit, + ) +} + +// QueryByBranch returns sessions filtered by branch, most recent first. +func (idb *InsightsDB) QueryByBranch(ctx context.Context, branch string, limit int) ([]SessionRow, error) { + return idb.querySessions(ctx, + "SELECT "+sessionColumns+" FROM sessions WHERE branch = ? ORDER BY created_at DESC LIMIT ?", + branch, limit, + ) +} + +// QueryByOwnerEmail returns sessions filtered by owner email, most recent first. +func (idb *InsightsDB) QueryByOwnerEmail(ctx context.Context, ownerEmail string, limit int) ([]SessionRow, error) { + return idb.querySessions(ctx, + "SELECT "+sessionColumns+" FROM sessions WHERE owner_email = ? ORDER BY created_at DESC LIMIT ?", + ownerEmail, limit, + ) +} + +// SessionCount returns the total number of cached sessions. +func (idb *InsightsDB) SessionCount(ctx context.Context) (int, error) { + var count int + if err := idb.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sessions").Scan(&count); err != nil { + return 0, fmt.Errorf("session count: %w", err) + } + return count, nil +} + +// QueryRecurringFriction returns friction themes occurring at least minCount times, +// ordered by count descending. +func (idb *InsightsDB) QueryRecurringFriction(ctx context.Context, minCount int) ([]FrictionTheme, error) { + rows, err := idb.db.QueryContext(ctx, ` + SELECT text, COUNT(*) AS cnt, GROUP_CONCAT(DISTINCT checkpoint_id) AS sessions + FROM friction + GROUP BY text + HAVING cnt >= ? + ORDER BY cnt DESC + `, minCount) + if err != nil { + return nil, fmt.Errorf("query recurring friction: %w", err) + } + defer rows.Close() + + var themes []FrictionTheme + for rows.Next() { + var theme FrictionTheme + var sessionsCSV string + if err = rows.Scan(&theme.Text, &theme.Count, &sessionsCSV); err != nil { + return nil, fmt.Errorf("scan friction theme: %w", err) + } + theme.Sessions = splitCSV(sessionsCSV) + themes = append(themes, theme) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate friction themes: %w", err) + } + return themes, nil +} + +// QuerySessionsWithFriction returns checkpoint IDs of sessions containing +// friction matching the given SQL LIKE pattern (e.g., "%tool call failed%"). +func (idb *InsightsDB) QuerySessionsWithFriction(ctx context.Context, pattern string) ([]string, error) { + rows, err := idb.db.QueryContext(ctx, + "SELECT DISTINCT checkpoint_id FROM friction WHERE text LIKE ?", + pattern, + ) + if err != nil { + return nil, fmt.Errorf("query sessions with friction: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err = rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan checkpoint id: %w", err) + } + ids = append(ids, id) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate session friction: %w", err) + } + return ids, nil +} + +// sessionColumns is the ordered column list for SELECT queries on the sessions table. +const sessionColumns = ` + checkpoint_id, session_id, session_index, + agent, model, branch, owner_name, owner_email, created_at, + input_tokens, cache_tokens, output_tokens, total_tokens, + api_call_count, duration_ms, turn_count, + intent, outcome, agent_percentage, + overall_score, score_token_efficiency, score_first_pass, + score_friction, score_focus, has_summary, has_facets` + +// querySessions executes a SELECT on sessions with the given args, +// then populates denormalized fields for each row. +func (idb *InsightsDB) querySessions(ctx context.Context, query string, args ...interface{}) ([]SessionRow, error) { + rows, err := idb.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query sessions: %w", err) + } + defer rows.Close() + + var sessions []SessionRow + for rows.Next() { + row, err := scanSession(rows) + if err != nil { + return nil, err + } + sessions = append(sessions, row) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate sessions: %w", err) + } + + for i := range sessions { + if err = idb.populateDenormalized(ctx, &sessions[i]); err != nil { + return nil, err + } + } + return sessions, nil +} + +// scanSession reads one row from the sessions table into a SessionRow. +func scanSession(rows *sql.Rows) (SessionRow, error) { + var row SessionRow + var createdAt string + var agent, model, branch, ownerName, ownerEmail, intent, outcome sql.NullString + var hasSummary, hasFacets int + + err := rows.Scan( + &row.CheckpointID, &row.SessionID, &row.SessionIndex, + &agent, &model, &branch, &ownerName, &ownerEmail, &createdAt, + &row.InputTokens, &row.CacheTokens, &row.OutputTokens, &row.TotalTokens, + &row.APICallCount, &row.DurationMs, &row.TurnCount, + &intent, &outcome, &row.AgentPct, + &row.OverallScore, &row.ScoreTokenEff, &row.ScoreFirstPass, + &row.ScoreFriction, &row.ScoreFocus, &hasSummary, &hasFacets, + ) + if err != nil { + return row, fmt.Errorf("scan session row: %w", err) + } + + row.Agent = agent.String + row.Model = model.String + row.Branch = branch.String + row.OwnerName = ownerName.String + row.OwnerEmail = ownerEmail.String + row.Intent = intent.String + row.Outcome = outcome.String + row.HasSummary = hasSummary == 1 + row.HasFacets = hasFacets == 1 + + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return row, fmt.Errorf("parse created_at %q: %w", createdAt, err) + } + row.CreatedAt = t + return row, nil +} + +// populateDenormalized loads files_touched, friction, and learnings for the session. +func (idb *InsightsDB) populateDenormalized(ctx context.Context, row *SessionRow) error { + var err error + row.FilesTouched, err = idb.loadFilesTouched(ctx, row.CheckpointID, row.SessionIndex) + if err != nil { + return err + } + row.Friction, err = idb.loadFriction(ctx, row.CheckpointID, row.SessionIndex) + if err != nil { + return err + } + row.Learnings, err = idb.loadLearnings(ctx, row.CheckpointID, row.SessionIndex) + if err != nil { + return err + } + row.ToolCounts, err = idb.loadToolCalls(ctx, row.CheckpointID, row.SessionIndex) + if err != nil { + return err + } + row.Facets, err = idb.loadFacets(ctx, row.CheckpointID, row.SessionIndex) + return err +} + +func (idb *InsightsDB) loadFilesTouched(ctx context.Context, checkpointID string, sessionIndex int) ([]string, error) { + rows, err := idb.db.QueryContext(ctx, + "SELECT file_path FROM files_touched WHERE checkpoint_id = ? AND session_index = ?", + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load files_touched: %w", err) + } + defer rows.Close() + + var files []string + for rows.Next() { + var f string + if err = rows.Scan(&f); err != nil { + return nil, fmt.Errorf("scan file_path: %w", err) + } + files = append(files, f) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate files_touched: %w", err) + } + return files, nil +} + +func (idb *InsightsDB) loadFriction(ctx context.Context, checkpointID string, sessionIndex int) ([]string, error) { + rows, err := idb.db.QueryContext(ctx, + "SELECT text FROM friction WHERE checkpoint_id = ? AND session_index = ?", + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load friction: %w", err) + } + defer rows.Close() + + var friction []string + for rows.Next() { + var f string + if err = rows.Scan(&f); err != nil { + return nil, fmt.Errorf("scan friction text: %w", err) + } + friction = append(friction, f) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate friction: %w", err) + } + return friction, nil +} + +func (idb *InsightsDB) loadLearnings(ctx context.Context, checkpointID string, sessionIndex int) ([]LearningRow, error) { + rows, err := idb.db.QueryContext(ctx, + "SELECT scope, finding, path FROM learnings WHERE checkpoint_id = ? AND session_index = ?", + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load learnings: %w", err) + } + defer rows.Close() + + var learnings []LearningRow + for rows.Next() { + var l LearningRow + var path sql.NullString + if err = rows.Scan(&l.Scope, &l.Finding, &path); err != nil { + return nil, fmt.Errorf("scan learning: %w", err) + } + l.Path = path.String + learnings = append(learnings, l) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate learnings: %w", err) + } + return learnings, nil +} + +func (idb *InsightsDB) loadToolCalls(ctx context.Context, checkpointID string, sessionIndex int) (map[string]int, error) { + rows, err := idb.db.QueryContext(ctx, + "SELECT tool_name, count FROM tool_calls WHERE checkpoint_id = ? AND session_index = ?", + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load tool_calls: %w", err) + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var name string + var count int + if err = rows.Scan(&name, &count); err != nil { + return nil, fmt.Errorf("scan tool_call: %w", err) + } + counts[name] = count + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate tool_calls: %w", err) + } + return counts, nil +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, ",") +} + +func (idb *InsightsDB) loadFacets(ctx context.Context, checkpointID string, sessionIndex int) (facets.SessionFacets, error) { + var result facets.SessionFacets + var err error + + result.RepeatedUserInstructions, err = idb.loadRepeatedInstructions(ctx, checkpointID, sessionIndex) + if err != nil { + return result, err + } + result.MissingContext, err = idb.loadMissingContext(ctx, checkpointID, sessionIndex) + if err != nil { + return result, err + } + result.FailureLoops, err = idb.loadFailureLoops(ctx, checkpointID, sessionIndex) + if err != nil { + return result, err + } + result.SkillSignals, err = idb.loadSkillSignals(ctx, checkpointID, sessionIndex) + if err != nil { + return result, err + } + return result, nil +} + +func (idb *InsightsDB) loadRepeatedInstructions(ctx context.Context, checkpointID string, sessionIndex int) ([]facets.RepeatedInstruction, error) { + rows, err := idb.db.QueryContext(ctx, + `SELECT instruction, evidence FROM repeated_user_instructions + WHERE checkpoint_id = ? AND session_index = ?`, + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load repeated_user_instructions: %w", err) + } + defer rows.Close() + + var values []facets.RepeatedInstruction + for rows.Next() { + var instruction string + var evidence sql.NullString + if err = rows.Scan(&instruction, &evidence); err != nil { + return nil, fmt.Errorf("scan repeated_user_instruction: %w", err) + } + values = append(values, facets.RepeatedInstruction{ + Instruction: instruction, + Evidence: splitEvidence(evidence.String), + }) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate repeated_user_instructions: %w", err) + } + return values, nil +} + +func (idb *InsightsDB) loadMissingContext(ctx context.Context, checkpointID string, sessionIndex int) ([]facets.MissingContextSignal, error) { + rows, err := idb.db.QueryContext(ctx, + `SELECT item, evidence FROM missing_context_signals + WHERE checkpoint_id = ? AND session_index = ?`, + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load missing_context_signals: %w", err) + } + defer rows.Close() + + var values []facets.MissingContextSignal + for rows.Next() { + var item string + var evidence sql.NullString + if err = rows.Scan(&item, &evidence); err != nil { + return nil, fmt.Errorf("scan missing_context_signal: %w", err) + } + values = append(values, facets.MissingContextSignal{ + Item: item, + Evidence: splitEvidence(evidence.String), + }) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate missing_context_signals: %w", err) + } + return values, nil +} + +func (idb *InsightsDB) loadFailureLoops(ctx context.Context, checkpointID string, sessionIndex int) ([]facets.FailureLoop, error) { + rows, err := idb.db.QueryContext(ctx, + `SELECT description, count, evidence FROM failure_loops + WHERE checkpoint_id = ? AND session_index = ?`, + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load failure_loops: %w", err) + } + defer rows.Close() + + var values []facets.FailureLoop + for rows.Next() { + var description string + var count int + var evidence sql.NullString + if err = rows.Scan(&description, &count, &evidence); err != nil { + return nil, fmt.Errorf("scan failure_loop: %w", err) + } + values = append(values, facets.FailureLoop{ + Description: description, + Count: count, + Evidence: splitEvidence(evidence.String), + }) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate failure_loops: %w", err) + } + return values, nil +} + +func (idb *InsightsDB) loadSkillSignals(ctx context.Context, checkpointID string, sessionIndex int) ([]facets.SkillSignal, error) { + rows, err := idb.db.QueryContext(ctx, + `SELECT skill_name, skill_path, friction, missing_instruction FROM skill_signals + WHERE checkpoint_id = ? AND session_index = ?`, + checkpointID, sessionIndex, + ) + if err != nil { + return nil, fmt.Errorf("load skill_signals: %w", err) + } + defer rows.Close() + + var values []facets.SkillSignal + for rows.Next() { + var skillName string + var skillPath, frictionText, missingInstruction sql.NullString + if err = rows.Scan(&skillName, &skillPath, &frictionText, &missingInstruction); err != nil { + return nil, fmt.Errorf("scan skill_signal: %w", err) + } + values = append(values, facets.SkillSignal{ + SkillName: skillName, + SkillPath: skillPath.String, + Friction: splitEvidence(frictionText.String), + MissingInstruction: missingInstruction.String, + }) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate skill_signals: %w", err) + } + return values, nil +} + +func splitEvidence(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +// SkillSignalRow represents a skill signal joined with its session metadata. +type SkillSignalRow struct { + CheckpointID string + SessionIndex int + SessionID string + Agent string + Model string + Branch string + CreatedAt time.Time + TotalTokens int + TurnCount int + OverallScore float64 + SkillName string + SkillPath string + Friction []string + MissingInstruction string +} + +// QuerySkillSignalsForSkills returns skill signals joined with session metadata +// for any skill whose name is in the given list. +func (idb *InsightsDB) QuerySkillSignalsForSkills(ctx context.Context, skillNames []string) ([]SkillSignalRow, error) { + if len(skillNames) == 0 { + return nil, nil + } + placeholders := make([]string, len(skillNames)) + args := make([]interface{}, len(skillNames)) + for i, name := range skillNames { + placeholders[i] = "?" + args[i] = name + } + query := fmt.Sprintf( //nolint:gosec // placeholders are all "?" literals, not user input + `SELECT s.checkpoint_id, s.session_index, s.session_id, + s.agent, s.model, s.branch, s.created_at, + s.total_tokens, s.turn_count, s.overall_score, + ss.skill_name, ss.skill_path, ss.friction, ss.missing_instruction + FROM skill_signals ss + JOIN sessions s ON s.checkpoint_id = ss.checkpoint_id AND s.session_index = ss.session_index + WHERE ss.skill_name IN (%s) + ORDER BY s.created_at DESC`, + strings.Join(placeholders, ","), + ) + rows, err := idb.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query skill signals: %w", err) + } + defer rows.Close() + + var results []SkillSignalRow + for rows.Next() { + var row SkillSignalRow + var sessionID, agent, model, branch, skillPath, frictionText, missingInstruction sql.NullString + var createdAt string + var overallScore sql.NullFloat64 + + if err = rows.Scan( + &row.CheckpointID, &row.SessionIndex, &sessionID, + &agent, &model, &branch, &createdAt, + &row.TotalTokens, &row.TurnCount, &overallScore, + &row.SkillName, &skillPath, &frictionText, &missingInstruction, + ); err != nil { + return nil, fmt.Errorf("scan skill signal row: %w", err) + } + row.SessionID = sessionID.String + row.Agent = agent.String + row.Model = model.String + row.Branch = branch.String + row.SkillPath = skillPath.String + row.Friction = splitEvidence(frictionText.String) + row.MissingInstruction = missingInstruction.String + row.OverallScore = overallScore.Float64 + t, parseErr := time.Parse(time.RFC3339, createdAt) + if parseErr != nil { + return nil, fmt.Errorf("parse created_at %q: %w", createdAt, parseErr) + } + row.CreatedAt = t + results = append(results, row) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate skill signals: %w", err) + } + return results, nil +} + +// SkillToolCallRow represents a session that has Skill tool invocations. +type SkillToolCallRow struct { + CheckpointID string + SessionIndex int + SessionID string + Agent string + Model string + Branch string + CreatedAt time.Time + TotalTokens int + TurnCount int + OverallScore float64 + SkillCount int +} + +// QuerySkillToolCallSessions returns sessions that have Skill tool invocations, +// useful for finding sessions where a skill was used without generating friction. +func (idb *InsightsDB) QuerySkillToolCallSessions(ctx context.Context) ([]SkillToolCallRow, error) { + rows, err := idb.db.QueryContext(ctx, ` + SELECT s.checkpoint_id, s.session_index, s.session_id, + s.agent, s.model, s.branch, s.created_at, + s.total_tokens, s.turn_count, s.overall_score, + tc.count + FROM tool_calls tc + JOIN sessions s ON s.checkpoint_id = tc.checkpoint_id AND s.session_index = tc.session_index + WHERE tc.tool_name = 'Skill' + ORDER BY s.created_at DESC`) + if err != nil { + return nil, fmt.Errorf("query skill tool call sessions: %w", err) + } + defer rows.Close() + + var results []SkillToolCallRow + for rows.Next() { + var row SkillToolCallRow + var sessionID, agent, model, branch sql.NullString + var createdAt string + var overallScore sql.NullFloat64 + + if err = rows.Scan( + &row.CheckpointID, &row.SessionIndex, &sessionID, + &agent, &model, &branch, &createdAt, + &row.TotalTokens, &row.TurnCount, &overallScore, + &row.SkillCount, + ); err != nil { + return nil, fmt.Errorf("scan skill tool call row: %w", err) + } + row.SessionID = sessionID.String + row.Agent = agent.String + row.Model = model.String + row.Branch = branch.String + row.OverallScore = overallScore.Float64 + t, parseErr := time.Parse(time.RFC3339, createdAt) + if parseErr != nil { + return nil, fmt.Errorf("parse created_at %q: %w", createdAt, parseErr) + } + row.CreatedAt = t + results = append(results, row) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate skill tool call sessions: %w", err) + } + return results, nil +} diff --git a/cmd/entire/cli/insightsdb/queries_test.go b/cmd/entire/cli/insightsdb/queries_test.go new file mode 100644 index 000000000..fccbe93fd --- /dev/null +++ b/cmd/entire/cli/insightsdb/queries_test.go @@ -0,0 +1,276 @@ +package insightsdb_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/facets" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/insightsdb" +) + +func TestQueryLastNSessions_EmptyWhenNone(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + sessions, err := db.QueryLastNSessions(ctx, 10) + require.NoError(t, err) + assert.Empty(t, sessions) +} + +func TestQueryLastNSessions_ReturnsOrderedByCreatedAtDesc(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := range 5 { + row := insightsdb.SessionRow{ + CheckpointID: "chk-order", + SessionID: "sess-order", + SessionIndex: i, + CreatedAt: base.Add(time.Duration(i) * time.Hour), + } + require.NoError(t, db.InsertSession(ctx, row)) + } + + sessions, err := db.QueryLastNSessions(ctx, 5) + require.NoError(t, err) + require.Len(t, sessions, 5) + + // Newest first + for i := 1; i < len(sessions); i++ { + assert.True(t, + sessions[i-1].CreatedAt.After(sessions[i].CreatedAt) || + sessions[i-1].CreatedAt.Equal(sessions[i].CreatedAt), + "sessions should be ordered newest first", + ) + } +} + +func TestQueryLastNSessions_RespectsLimit(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + for i := range 10 { + require.NoError(t, db.InsertSession(ctx, minimalSessionRow("chk-limit", "sess", i))) + } + + sessions, err := db.QueryLastNSessions(ctx, 3) + require.NoError(t, err) + assert.Len(t, sessions, 3) +} + +func TestQueryByAgent_FiltersCorrectly(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + agents := []string{"claude-code", "claude-code", "gemini-cli"} + for i, agent := range agents { + row := minimalSessionRow("chk-agent-"+agent, "sess", i) + row.Agent = agent + require.NoError(t, db.InsertSession(ctx, row)) + } + + claudeSessions, err := db.QueryByAgent(ctx, "claude-code", 10) + require.NoError(t, err) + assert.Len(t, claudeSessions, 2) + for _, s := range claudeSessions { + assert.Equal(t, "claude-code", s.Agent) + } + + geminiSessions, err := db.QueryByAgent(ctx, "gemini-cli", 10) + require.NoError(t, err) + assert.Len(t, geminiSessions, 1) +} + +func TestQueryByAgent_RespectsLimit(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + for i := range 5 { + row := minimalSessionRow("chk-al", "sess", i) + row.Agent = "claude-code" + require.NoError(t, db.InsertSession(ctx, row)) + } + + sessions, err := db.QueryByAgent(ctx, "claude-code", 2) + require.NoError(t, err) + assert.Len(t, sessions, 2) +} + +func TestQueryByAgent_EmptyWhenAgentNotFound(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + sessions, err := db.QueryByAgent(ctx, "unknown-agent", 10) + require.NoError(t, err) + assert.Empty(t, sessions) +} + +func TestQueryRecurringFriction_ReturnsThemesAboveMinCount(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + // Insert 3 sessions with DIFFERENT checkpoint IDs but same friction text + for i := range 3 { + cpID := fmt.Sprintf("chk-friction-%d", i) + row := minimalSessionRow(cpID, "sess", 0) + row.Friction = []string{"tool call failed", "unique friction " + string(rune('A'+i))} + require.NoError(t, db.InsertSession(ctx, row)) + } + + themes, err := db.QueryRecurringFriction(ctx, 2) + require.NoError(t, err) + require.Len(t, themes, 1) + assert.Equal(t, "tool call failed", themes[0].Text) + assert.Equal(t, 3, themes[0].Count) + assert.Len(t, themes[0].Sessions, 3) +} + +func TestQueryRecurringFriction_ExcludesBelowMinCount(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row := minimalSessionRow("chk-rare", "sess", 0) + row.Friction = []string{"rare friction"} + require.NoError(t, db.InsertSession(ctx, row)) + + themes, err := db.QueryRecurringFriction(ctx, 2) + require.NoError(t, err) + assert.Empty(t, themes) +} + +func TestQueryRecurringFriction_OrderedByCountDesc(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + // "common" appears 3 times, "less common" appears 2 times + for i := range 3 { + row := minimalSessionRow("chk-order-f", "sess", i) + row.Friction = []string{"common friction"} + if i < 2 { + row.Friction = append(row.Friction, "less common friction") + } + require.NoError(t, db.InsertSession(ctx, row)) + } + + themes, err := db.QueryRecurringFriction(ctx, 2) + require.NoError(t, err) + require.Len(t, themes, 2) + assert.Equal(t, "common friction", themes[0].Text) + assert.Equal(t, 3, themes[0].Count) + assert.Equal(t, "less common friction", themes[1].Text) + assert.Equal(t, 2, themes[1].Count) +} + +func TestQuerySessionsWithFriction_PatternMatch(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row1 := minimalSessionRow("chk-pf1", "sess", 0) + row1.Friction = []string{"tool call failed: timeout"} + + row2 := minimalSessionRow("chk-pf2", "sess", 0) + row2.Friction = []string{"tool call failed: rate limit"} + + row3 := minimalSessionRow("chk-pf3", "sess", 0) + row3.Friction = []string{"unrelated issue"} + + require.NoError(t, db.InsertSession(ctx, row1)) + require.NoError(t, db.InsertSession(ctx, row2)) + require.NoError(t, db.InsertSession(ctx, row3)) + + ids, err := db.QuerySessionsWithFriction(ctx, "%tool call failed%") + require.NoError(t, err) + assert.Len(t, ids, 2) + assert.Contains(t, ids, "chk-pf1") + assert.Contains(t, ids, "chk-pf2") +} + +func TestQuerySessionsWithFriction_EmptyWhenNoMatch(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + ids, err := db.QuerySessionsWithFriction(ctx, "%nonexistent%") + require.NoError(t, err) + assert.Empty(t, ids) +} + +func TestSessionCount_Zero(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + count, err := db.SessionCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestSessionCount_AfterInserts(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + for i := range 5 { + require.NoError(t, db.InsertSession(ctx, minimalSessionRow("chk-count", "sess", i))) + } + + count, err := db.SessionCount(ctx) + require.NoError(t, err) + assert.Equal(t, 5, count) +} + +func TestQueryLastNSessions_LoadsStructuredFacets(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + row := minimalSessionRow("chk-fq", "sess", 0) + row.Facets = facets.SessionFacets{ + RepeatedUserInstructions: []facets.RepeatedInstruction{ + {Instruction: "Run tests before committing", Evidence: []string{"User asked twice"}}, + }, + MissingContext: []facets.MissingContextSignal{ + {Item: "Repo requires canary after prompt changes", Evidence: []string{"Vogon parser mismatch"}}, + }, + } + require.NoError(t, db.InsertSession(ctx, row)) + + sessions, err := db.QueryLastNSessions(ctx, 1) + require.NoError(t, err) + require.Len(t, sessions, 1) + assert.True(t, sessions[0].HasFacets) + require.Len(t, sessions[0].Facets.RepeatedUserInstructions, 1) + assert.Equal(t, "Run tests before committing", sessions[0].Facets.RepeatedUserInstructions[0].Instruction) + require.Len(t, sessions[0].Facets.MissingContext, 1) + assert.Equal(t, "Repo requires canary after prompt changes", sessions[0].Facets.MissingContext[0].Item) +} diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index a4ffca7a8..886834737 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -19,14 +19,18 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/transcript" "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/perf" ) +const memoryLoopClaudeAgentName = "claude-code" + // DispatchLifecycleEvent routes a normalized lifecycle event to the appropriate handler. // Returns nil if the event was handled successfully. func DispatchLifecycleEvent(ctx context.Context, ag agent.Agent, event *agent.Event) error { @@ -250,9 +254,103 @@ func handleLifecycleTurnStart(ctx context.Context, ag agent.Agent, event *agent. } initSpan.End() + if err := maybeInjectMemoryLoop(ctx, ag, event); err != nil { + return err + } + + return nil +} + +func maybeInjectMemoryLoop(ctx context.Context, ag agent.Agent, event *agent.Event) error { + if string(ag.Name()) != memoryLoopClaudeAgentName || event.Prompt == "" { + return nil + } + + writer, ok := agent.AsHookResponseWriter(ag) + if !ok { + return nil + } + + state, loadErr := memoryloop.LoadState(ctx) + if loadErr != nil { + state = &memoryloop.State{} + } + settingsValue, settingsErr := LoadEntireSettings(ctx) + if settingsErr != nil { + settingsValue = nil + } + if effectiveMemoryLoopMode(state, settingsValue) != memoryloop.ModeAuto { + return nil + } + + if state.Snapshot == nil || !state.Snapshot.InjectionEnabled { + return nil + } + + now := time.Now().UTC() + matches := memoryloop.SelectRelevant(*state.Snapshot, event.Prompt, now) + if len(matches) == 0 { + return nil + } + + message := memoryloop.FormatInjectionBlock(matches) + if strings.TrimSpace(event.ResponseMessage) != "" { + message = strings.TrimSpace(event.ResponseMessage) + "\n\n" + message + } + if err := writer.WriteHookResponse(message); err != nil { + return fmt.Errorf("failed to write memory-loop hook response: %w", err) + } + + ids := make([]string, 0, len(matches)) + reasons := make([]string, 0, len(matches)) + for _, match := range matches { + ids = append(ids, match.Record.ID) + if match.Reason != "" { + reasons = append(reasons, match.Reason) + } + } + + logEntry := memoryloop.InjectionLog{ + SessionID: event.SessionID, + PromptPreview: truncatePromptPreview(event.Prompt, 500), + InjectedMemoryIDs: ids, + InjectedAt: now, + Reason: strings.Join(reasons, ", "), + } + memoryloop.RecordInjectionActivity(state, matches, logEntry, now) + if err := memoryloop.SaveState(ctx, state); err != nil { + logging.Warn(logging.WithComponent(ctx, "lifecycle"), "failed to persist memory-loop injection activity", + slog.String("error", err.Error())) + } + return nil } +func effectiveMemoryLoopMode(state *memoryloop.State, settingsValue *settings.EntireSettings) memoryloop.Mode { + if state != nil && state.Store != nil { + return state.Store.Mode + } + if settingsValue == nil || settingsValue.MemoryLoopConfig == nil { + return memoryloop.ModeOff + } + cfg := settingsValue.GetMemoryLoopConfig() + if cfg.Mode == "" && !cfg.Enabled { + return memoryloop.ModeOff + } + return memoryloop.Mode(cfg.Mode) +} + +func truncatePromptPreview(prompt string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(prompt)) + if len(runes) <= maxLen { + return string(runes) + } + return string(runes[:maxLen]) + "..." +} + // handleLifecycleTurnEnd handles turn end: validates transcript, extracts metadata, // detects file changes, saves step + checkpoint, transitions phase. // diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index df8d04245..ec13851c4 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -10,7 +10,9 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/go-git/go-git/v6" @@ -18,6 +20,8 @@ import ( "github.com/stretchr/testify/require" ) +const testClaudeCodeAgentType = "Claude Code" + // mockLifecycleAgent is a minimal Agent implementation for lifecycle tests. type mockLifecycleAgent struct { name types.AgentName @@ -178,6 +182,10 @@ func newMockHookResponseAgent() *mockHookResponseAgent { } } +func boolPtr(v bool) *bool { + return &v +} + func TestHandleLifecycleSessionStart_EmptyRepoWarning(t *testing.T) { // Cannot use t.Parallel() because we use t.Chdir() tmpDir := t.TempDir() @@ -714,6 +722,270 @@ func TestHandleLifecycleTurnStart_WritesPromptContent(t *testing.T) { } } +func TestHandleLifecycleTurnStart_InjectsMemoryForClaude(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".entire", "memory-loop.json"), []byte(`{ + "snapshot": { + "version": 1, + "generated_at": "2026-03-25T12:00:00Z", + "source_window": 20, + "injection_enabled": true, + "max_injected": 3, + "records": [ + { + "id": "lint", + "kind": "repo_rule", + "title": "Run lint before finishing", + "body": "Run golangci-lint before claiming completion.", + "why": "This repo frequently fails on lint after edits.", + "confidence": "high", + "strength": 5, + "status": "active", + "created_at": "2026-03-25T12:00:00Z", + "updated_at": "2026-03-25T12:00:00Z" + } + ] + } +}`), 0o644)) + + ag := newMockHookResponseAgent() + ag.name = memoryLoopClaudeAgentName + ag.agentType = testClaudeCodeAgentType + + event := &agent.Event{ + Type: agent.TurnStart, + SessionID: "test-memory-injection", + Prompt: "fix the lint failure in capabilities.go", + Timestamp: time.Now(), + } + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + require.Contains(t, ag.lastMessage, "Memory For This Repo") + require.Contains(t, ag.lastMessage, "Run lint before finishing") +} + +func TestHandleLifecycleTurnStart_RecordsMemoryActivityForInjectedMatches(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + GeneratedAt: time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC), + SourceWindow: 20, + Mode: memoryloop.ModeAuto, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "lint", + Kind: memoryloop.KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Status: memoryloop.StatusActive, + ScopeKind: memoryloop.ScopeKindMe, + CreatedAt: time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC), + }, + }, + }, + })) + + ag := newMockHookResponseAgent() + ag.name = memoryLoopClaudeAgentName + ag.agentType = testClaudeCodeAgentType + + event := &agent.Event{ + Type: agent.TurnStart, + SessionID: "test-memory-activity", + Prompt: "fix the lint failure in capabilities.go", + Timestamp: time.Now(), + } + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.NotNil(t, loaded.Store) + require.Len(t, loaded.Store.Records, 1) + require.Equal(t, 1, loaded.Store.Records[0].MatchCount) + require.Equal(t, 1, loaded.Store.Records[0].InjectCount) + require.False(t, loaded.Store.Records[0].LastMatchedAt.IsZero()) + require.False(t, loaded.Store.Records[0].LastInjectedAt.IsZero()) + require.Len(t, loaded.InjectionLogs, 1) + require.Equal(t, "test-memory-activity", loaded.InjectionLogs[0].SessionID) +} + +func TestHandleLifecycleTurnStart_StoreModeOverridesLegacySettingsGate(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".entire", "settings.json"), []byte(`{ + "enabled": true, + "memory_loop": { + "enabled": true, + "claude_injection_enabled": false + } +}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".entire", "memory-loop.json"), []byte(`{ + "store": { + "version": 1, + "generated_at": "2026-03-25T12:00:00Z", + "source_window": 20, + "mode": "auto", + "activation_policy": "review", + "max_injected": 3, + "records": [ + { + "id": "lint", + "kind": "repo_rule", + "title": "Run lint before finishing", + "body": "Run golangci-lint before claiming completion.", + "why": "This repo frequently fails on lint after edits.", + "confidence": "high", + "strength": 5, + "status": "active", + "created_at": "2026-03-25T12:00:00Z", + "updated_at": "2026-03-25T12:00:00Z" + } + ] + } +}`), 0o644)) + + ag := newMockHookResponseAgent() + ag.name = memoryLoopClaudeAgentName + ag.agentType = testClaudeCodeAgentType + + event := &agent.Event{ + Type: agent.TurnStart, + SessionID: "test-store-mode-overrides-settings", + Prompt: "fix the lint failure in capabilities.go", + Timestamp: time.Now(), + } + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + require.Contains(t, ag.lastMessage, "Memory For This Repo") +} + +func TestHandleLifecycleTurnStart_RecordsMemoryLoopActivity(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".entire", "memory-loop.json"), []byte(`{ + "store": { + "version": 1, + "generated_at": "2026-03-25T12:00:00Z", + "source_window": 20, + "mode": "auto", + "activation_policy": "review", + "max_injected": 3, + "records": [ + { + "id": "lint", + "kind": "repo_rule", + "title": "Run lint before finishing", + "body": "Run golangci-lint before claiming completion.", + "status": "active", + "created_at": "2026-03-25T12:00:00Z", + "updated_at": "2026-03-25T12:00:00Z" + } + ] + } +}`), 0o644)) + + ag := newMockHookResponseAgent() + ag.name = memoryLoopClaudeAgentName + ag.agentType = testClaudeCodeAgentType + + event := &agent.Event{ + Type: agent.TurnStart, + SessionID: "test-memory-activity", + Prompt: "fix the lint failure in capabilities.go", + Timestamp: time.Now(), + } + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + + state, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Len(t, state.Store.Records, 1) + require.Equal(t, 1, state.Store.Records[0].MatchCount) + require.Equal(t, 1, state.Store.Records[0].InjectCount) + require.False(t, state.Store.Records[0].LastMatchedAt.IsZero()) + require.False(t, state.Store.Records[0].LastInjectedAt.IsZero()) + require.Len(t, state.InjectionLogs, 1) + require.Equal(t, "test-memory-activity", state.InjectionLogs[0].SessionID) +} + +func TestEffectiveMemoryLoopMode_PreStoreUsesModeDerivedFromSettings(t *testing.T) { + t.Parallel() + + settingsValue := &settings.EntireSettings{ + MemoryLoopConfig: &settings.MemoryLoopSettings{ + Enabled: true, + ClaudeInjectionEnabled: boolPtr(false), + }, + } + require.Equal(t, memoryloop.ModeManual, effectiveMemoryLoopMode(&memoryloop.State{}, settingsValue)) + + settingsValue = &settings.EntireSettings{ + MemoryLoopConfig: &settings.MemoryLoopSettings{ + Enabled: true, + ClaudeInjectionEnabled: boolPtr(true), + }, + } + require.Equal(t, memoryloop.ModeAuto, effectiveMemoryLoopMode(&memoryloop.State{}, settingsValue)) + + settingsValue = &settings.EntireSettings{ + MemoryLoopConfig: &settings.MemoryLoopSettings{ + Enabled: true, + Mode: "manual", + ClaudeInjectionEnabled: boolPtr(true), + }, + } + require.Equal(t, memoryloop.ModeManual, effectiveMemoryLoopMode(&memoryloop.State{}, settingsValue)) +} + +func TestEffectiveMemoryLoopMode_PreStoreExplicitModeBeatsLegacyEnabled(t *testing.T) { + t.Parallel() + + settingsValue := &settings.EntireSettings{ + MemoryLoopConfig: &settings.MemoryLoopSettings{ + Enabled: false, + Mode: "auto", + }, + } + require.Equal(t, memoryloop.ModeAuto, effectiveMemoryLoopMode(&memoryloop.State{}, settingsValue)) +} + func TestHandleLifecycleTurnEnd_BackfillsPromptFromTranscript(t *testing.T) { // Cannot use t.Parallel() because we use t.Chdir() tmpDir := t.TempDir() diff --git a/cmd/entire/cli/llmcli/llmcli.go b/cmd/entire/cli/llmcli/llmcli.go new file mode 100644 index 000000000..b81059651 --- /dev/null +++ b/cmd/entire/cli/llmcli/llmcli.go @@ -0,0 +1,172 @@ +// Package llmcli provides a shared runner for executing prompts via the Claude CLI. +package llmcli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// DefaultModel is the default Claude model used when Model is not set. +// Sonnet provides a good balance of quality and cost, with 1M context window +// to handle long transcripts without truncation. +const DefaultModel = "sonnet" + +// Runner executes prompts via the Claude CLI. +type Runner struct { + // ClaudePath is the path to the claude CLI executable. + // If empty, defaults to "claude". + ClaudePath string + + // Model is the Claude model to use. + // If empty, defaults to DefaultModel ("sonnet"). + Model string + + // CommandRunner allows injection of the command execution for testing. + // If nil, uses exec.CommandContext directly. + CommandRunner func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// UsageInfo contains token usage and cost data from a Claude CLI invocation. +type UsageInfo struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalCostUSD float64 `json:"total_cost_usd"` +} + +// claudeCLIResponse represents the JSON response from the Claude CLI. +type claudeCLIResponse struct { + Result string `json:"result"` + TotalCostUSD float64 `json:"total_cost_usd"` + Usage json.RawMessage `json:"usage"` +} + +// claudeCLIUsage represents the usage field in the Claude CLI JSON response. +type claudeCLIUsage struct { + InputTokens int `json:"input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// Execute runs a prompt through the Claude CLI and returns the raw text result +// along with token usage and cost information. +// +// It handles: +// - CLI invocation with --print --output-format json --model --setting-sources "" +// - Git isolation (TempDir cwd, strip GIT_* env vars) +// - Response parsing ({"result": "string"} format) +// - Markdown code block extraction from the result field +func (r *Runner) Execute(ctx context.Context, prompt string) (string, *UsageInfo, error) { + runner := r.CommandRunner + if runner == nil { + runner = exec.CommandContext + } + + claudePath := r.ClaudePath + if claudePath == "" { + claudePath = "claude" + } + + model := r.Model + if model == "" { + model = DefaultModel + } + + // Use empty --setting-sources to skip all settings (user, project, local). + // This avoids loading MCP servers, hooks, or other config that could interfere + // with a simple --print call. + cmd := runner(ctx, claudePath, "--print", "--output-format", "json", "--model", model, "--setting-sources", "") + + // Fully isolate the subprocess from the user's git repo (ENT-242). + // Claude Code performs internal git operations (plugin cache, context gathering) + // that pollute the worktree index with phantom entries from its plugin cache. + // We must both change the working directory AND strip GIT_* env vars, because + // git hooks set GIT_DIR which lets Claude Code find the repo regardless of cwd. + // This also prevents recursive triggering of Entire's own git hooks. + cmd.Dir = os.TempDir() + cmd.Env = StripGitEnv(os.Environ()) + + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", nil, fmt.Errorf("claude CLI not found: %w", err) + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", nil, fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) + } + + return "", nil, fmt.Errorf("failed to run claude CLI: %w", err) + } + + var cliResponse claudeCLIResponse + if err := json.Unmarshal(stdout.Bytes(), &cliResponse); err != nil { + return "", nil, fmt.Errorf("failed to parse claude CLI response: %w", err) + } + + // Extract JSON if it's wrapped in markdown code blocks. + result := ExtractJSONFromMarkdown(cliResponse.Result) + + usage := &UsageInfo{TotalCostUSD: cliResponse.TotalCostUSD} + if len(cliResponse.Usage) > 0 { + var u claudeCLIUsage + if err := json.Unmarshal(cliResponse.Usage, &u); err == nil { + usage.InputTokens = u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens + usage.OutputTokens = u.OutputTokens + } + } + + return result, usage, nil +} + +// StripGitEnv returns a copy of env with all GIT_* variables removed. +// This prevents a subprocess from discovering or modifying the parent's git repo. +func StripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} + +// ExtractJSONFromMarkdown attempts to extract JSON from markdown code blocks. +// If the input is not wrapped in code blocks, it returns the input unchanged. +func ExtractJSONFromMarkdown(s string) string { + s = strings.TrimSpace(s) + + // Check for ```json ... ``` blocks + if strings.HasPrefix(s, "```json") { + s = strings.TrimPrefix(s, "```json") + if idx := strings.LastIndex(s, "```"); idx != -1 { + s = s[:idx] + } + return strings.TrimSpace(s) + } + + // Check for ``` ... ``` blocks + if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```") + if idx := strings.LastIndex(s, "```"); idx != -1 { + s = s[:idx] + } + return strings.TrimSpace(s) + } + + return s +} diff --git a/cmd/entire/cli/llmcli/llmcli_test.go b/cmd/entire/cli/llmcli/llmcli_test.go new file mode 100644 index 000000000..335cc3f5d --- /dev/null +++ b/cmd/entire/cli/llmcli/llmcli_test.go @@ -0,0 +1,379 @@ +package llmcli_test + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/llmcli" +) + +func TestStripGitEnv(t *testing.T) { + t.Parallel() + + env := []string{ + "HOME=/Users/test", + "GIT_DIR=/repo/.git", + "PATH=/usr/bin", + "GIT_WORK_TREE=/repo", + "GIT_INDEX_FILE=/repo/.git/index", + "SHELL=/bin/zsh", + } + + filtered := llmcli.StripGitEnv(env) + + expected := []string{ + "HOME=/Users/test", + "PATH=/usr/bin", + "SHELL=/bin/zsh", + } + + if len(filtered) != len(expected) { + t.Fatalf("got %d entries, want %d", len(filtered), len(expected)) + } + + for i, e := range filtered { + if e != expected[i] { + t.Errorf("filtered[%d] = %q, want %q", i, e, expected[i]) + } + } +} + +func TestStripGitEnv_Empty(t *testing.T) { + t.Parallel() + + result := llmcli.StripGitEnv([]string{}) + if len(result) != 0 { + t.Errorf("expected empty slice, got %v", result) + } +} + +func TestStripGitEnv_NoGitVars(t *testing.T) { + t.Parallel() + + env := []string{"HOME=/Users/test", "PATH=/usr/bin"} + result := llmcli.StripGitEnv(env) + if len(result) != 2 { + t.Errorf("expected 2 entries, got %d", len(result)) + } +} + +func TestExtractJSONFromMarkdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "plain JSON", + input: `{"key": "value"}`, + expected: `{"key": "value"}`, + }, + { + name: "json code block", + input: "```json\n{\"key\": \"value\"}\n```", + expected: `{"key": "value"}`, + }, + { + name: "plain code block", + input: "```\n{\"key\": \"value\"}\n```", + expected: `{"key": "value"}`, + }, + { + name: "with whitespace", + input: " \n```json\n{\"key\": \"value\"}\n``` \n", + expected: `{"key": "value"}`, + }, + { + name: "unclosed block", + input: "```json\n{\"key\": \"value\"}", + expected: `{"key": "value"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := llmcli.ExtractJSONFromMarkdown(tt.input) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestRunner_Execute_GitIsolation(t *testing.T) { + var capturedCmd *exec.Cmd + + response := `{"result": "hello world"}` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + capturedCmd = cmd + return cmd + }, + } + + t.Setenv("GIT_DIR", "/some/repo/.git") + t.Setenv("GIT_WORK_TREE", "/some/repo") + t.Setenv("GIT_INDEX_FILE", "/some/repo/.git/index") + + _, _, err := runner.Execute(context.Background(), "test prompt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedCmd == nil { + t.Fatal("command was not captured") + } + + if capturedCmd.Dir != os.TempDir() { + t.Errorf("cmd.Dir = %q, want %q", capturedCmd.Dir, os.TempDir()) + } + + for _, env := range capturedCmd.Env { + if strings.HasPrefix(env, "GIT_") { + t.Errorf("found GIT_* env var in subprocess: %s", env) + } + } +} + +func TestRunner_Execute_ValidResponse(t *testing.T) { + t.Parallel() + + response := `{"result": "the actual result text"}` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + result, _, err := runner.Execute(context.Background(), "test prompt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != "the actual result text" { + t.Errorf("expected %q, got %q", "the actual result text", result) + } +} + +func TestRunner_Execute_MarkdownResult(t *testing.T) { + t.Parallel() + + // result field contains JSON wrapped in markdown code block + innerJSON := "```json\\n{\\\"key\\\":\\\"value\\\"}\\n```" + response := `{"result":"` + innerJSON + `"}` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + result, _, err := runner.Execute(context.Background(), "test prompt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != `{"key":"value"}` { + t.Errorf("expected extracted JSON, got %q", result) + } +} + +func TestRunner_Execute_CommandNotFound(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "nonexistent-command-that-should-not-exist-12345") + }, + } + + _, _, err := runner.Execute(context.Background(), "test prompt") + if err == nil { + t.Fatal("expected error when command not found") + } + + if !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "executable file not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +func TestRunner_Execute_NonZeroExit(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "echo 'error message' >&2; exit 1") + }, + } + + _, _, err := runner.Execute(context.Background(), "test prompt") + if err == nil { + t.Fatal("expected error on non-zero exit") + } + + if !strings.Contains(err.Error(), "exit 1") { + t.Errorf("expected exit code in error, got: %v", err) + } +} + +func TestRunner_Execute_InvalidJSONResponse(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "echo", "not valid json") + }, + } + + _, _, err := runner.Execute(context.Background(), "test prompt") + if err == nil { + t.Fatal("expected error for invalid JSON response") + } + + if !strings.Contains(err.Error(), "parse claude CLI response") { + t.Errorf("expected parse error, got: %v", err) + } +} + +func TestRunner_Defaults(t *testing.T) { + t.Parallel() + + var capturedName string + var capturedArgs []string + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, name string, args ...string) *exec.Cmd { + capturedName = name + capturedArgs = args + response := `{"result": "ok"}` + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + _, _, err := runner.Execute(context.Background(), "test prompt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedName != "claude" { + t.Errorf("expected default claude path 'claude', got %q", capturedName) + } + + // Check that --model defaults to "sonnet" + foundModel := false + for i, arg := range capturedArgs { + if arg == "--model" && i+1 < len(capturedArgs) && capturedArgs[i+1] == llmcli.DefaultModel { + foundModel = true + break + } + } + if !foundModel { + t.Errorf("expected --model %s in args, got %v", llmcli.DefaultModel, capturedArgs) + } +} + +func TestRunner_Execute_ParsesUsage(t *testing.T) { + t.Parallel() + + response := `{"result":"ok","total_cost_usd":0.0123,"usage":{"input_tokens":100,"cache_creation_input_tokens":50,"cache_read_input_tokens":25,"output_tokens":200}}` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + result, usage, err := runner.Execute(context.Background(), "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "ok" { + t.Errorf("expected result 'ok', got %q", result) + } + if usage == nil { + t.Fatal("expected non-nil usage") + } + if usage.TotalCostUSD != 0.0123 { + t.Errorf("expected TotalCostUSD=0.0123, got %f", usage.TotalCostUSD) + } + if usage.InputTokens != 175 { // 100 + 50 + 25 + t.Errorf("expected InputTokens=175, got %d", usage.InputTokens) + } + if usage.OutputTokens != 200 { + t.Errorf("expected OutputTokens=200, got %d", usage.OutputTokens) + } +} + +func TestRunner_Execute_UsageMissing(t *testing.T) { + t.Parallel() + + response := `{"result":"ok"}` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + _, usage, err := runner.Execute(context.Background(), "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if usage == nil { + t.Fatal("expected non-nil usage") + } + // Zero values when usage field is absent + if usage.InputTokens != 0 { + t.Errorf("expected InputTokens=0, got %d", usage.InputTokens) + } + if usage.OutputTokens != 0 { + t.Errorf("expected OutputTokens=0, got %d", usage.OutputTokens) + } +} + +func TestRunner_CustomClaudePathAndModel(t *testing.T) { + t.Parallel() + + var capturedName string + var capturedArgs []string + + runner := &llmcli.Runner{ + ClaudePath: "/custom/claude", + Model: "opus", + CommandRunner: func(ctx context.Context, name string, args ...string) *exec.Cmd { + capturedName = name + capturedArgs = args + response := `{"result": "ok"}` + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + _, _, err := runner.Execute(context.Background(), "test prompt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedName != "/custom/claude" { + t.Errorf("expected /custom/claude, got %q", capturedName) + } + + foundModel := false + for i, arg := range capturedArgs { + if arg == "--model" && i+1 < len(capturedArgs) && capturedArgs[i+1] == "opus" { + foundModel = true + break + } + } + if !foundModel { + t.Errorf("expected --model opus in args, got %v", capturedArgs) + } +} diff --git a/cmd/entire/cli/memory_loop_cmd.go b/cmd/entire/cli/memory_loop_cmd.go new file mode 100644 index 000000000..56dccc746 --- /dev/null +++ b/cmd/entire/cli/memory_loop_cmd.go @@ -0,0 +1,1077 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/improve" + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/llmcli" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" + "github.com/entireio/cli/cmd/entire/cli/memorylooptui" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/termstyle" + "github.com/spf13/cobra" +) + +type memoryLoopScopeMode string + +const ( + memoryLoopScopeRepo memoryLoopScopeMode = "repo" + memoryLoopScopeBranch memoryLoopScopeMode = "branch" + memoryLoopScopeMe memoryLoopScopeMode = "me" +) + +type memoryLoopScope struct { + Mode memoryLoopScopeMode + Branch string + OwnerEmail string +} + +const ( + memoryLoopAddScopeFlagKind = "kind" + memoryLoopAddScopeFlagTitle = "title" + memoryLoopAddScopeFlagBody = "body" +) + +type memoryLoopRefreshSummary struct { + SessionCount int + ScopeLabel string + GeneratedCount int + ActivatedCount int + CandidateCount int + ActiveCount int + StoredCandidateCount int + SuppressedCount int + ArchivedCount int + GeneratedTitles []string +} + +type memoryLoopAddOptions struct { + Kind string + Title string + Body string + Scope string +} + +func newMemoryLoopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "memory-loop", + Short: "Build and inspect repo-scoped memory for future Claude sessions", + } + + cmd.AddCommand(newMemoryLoopRefreshCmd()) + cmd.AddCommand(newMemoryLoopShowCmd()) + cmd.AddCommand(newMemoryLoopStatusCmd()) + cmd.AddCommand(newMemoryLoopModeCmd()) + cmd.AddCommand(newMemoryLoopPolicyCmd()) + cmd.AddCommand(newMemoryLoopAddCmd()) + cmd.AddCommand(newMemoryLoopActivateCmd()) + cmd.AddCommand(newMemoryLoopPromoteCmd()) + cmd.AddCommand(newMemoryLoopSuppressCmd()) + cmd.AddCommand(newMemoryLoopUnsuppressCmd()) + cmd.AddCommand(newMemoryLoopArchiveCmd()) + cmd.AddCommand(newMemoryLoopPruneCmd()) + cmd.AddCommand(newMemoryLoopTuiCmd()) + + return cmd +} + +func newMemoryLoopTuiCmd() *cobra.Command { + return &cobra.Command{ + Use: "tui", + Short: "Interactive memory loop dashboard", + RunE: func(cmd *cobra.Command, _ []string) error { + if IsAccessibleMode() { + return runMemoryLoopShow(cmd.Context(), cmd.OutOrStdout(), "") + } + return memorylooptui.Run(cmd.Context()) + }, + } +} + +func newMemoryLoopAddCmd() *cobra.Command { + opts := memoryLoopAddOptions{Scope: string(memoryLoopScopeMe)} + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a manual memory entry", + RunE: func(cmd *cobra.Command, _ []string) error { + return runMemoryLoopAdd(cmd.Context(), cmd.OutOrStdout(), opts) + }, + } + cmd.Flags().StringVar(&opts.Kind, "kind", "", "memory kind: repo_rule, workflow_rule, agent_instruction, skill_patch, anti_pattern") + cmd.Flags().StringVar(&opts.Title, "title", "", "memory title") + cmd.Flags().StringVar(&opts.Body, "body", "", "memory body") + cmd.Flags().StringVar(&opts.Scope, "scope", opts.Scope, "memory scope: me or repo") + mustMarkFlagRequired(cmd, memoryLoopAddScopeFlagKind) + mustMarkFlagRequired(cmd, memoryLoopAddScopeFlagTitle) + mustMarkFlagRequired(cmd, memoryLoopAddScopeFlagBody) + return cmd +} + +func newMemoryLoopRefreshCmd() *cobra.Command { + var last int + var scope string + var branch string + + cmd := &cobra.Command{ + Use: "refresh", + Short: "Rebuild the memory snapshot from recent sessions", + RunE: func(cmd *cobra.Command, _ []string) error { + return runMemoryLoopRefresh(cmd.Context(), cmd.OutOrStdout(), last, scope, branch) + }, + } + cmd.Flags().IntVar(&last, "last", memoryloop.DefaultRefreshWindow, "number of recent sessions to distill into memory") + cmd.Flags().StringVar(&scope, "scope", string(memoryLoopScopeRepo), "session scope to use: repo, branch, or me") + cmd.Flags().StringVar(&branch, "branch", "", "branch name to use when --scope branch (defaults to current branch)") + return cmd +} + +func newMemoryLoopShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Show the current memory snapshot and recent injection history", + RunE: func(cmd *cobra.Command, _ []string) error { + return runMemoryLoopShow(cmd.Context(), cmd.OutOrStdout(), "") + }, + } +} + +func newMemoryLoopStatusCmd() *cobra.Command { + var prompt string + var verbose bool + + cmd := &cobra.Command{ + Use: "status", + Short: "Show memory-loop status and optional prompt preview", + RunE: func(cmd *cobra.Command, _ []string) error { + return runMemoryLoopStatus(cmd.Context(), cmd.OutOrStdout(), prompt, verbose) + }, + } + cmd.Flags().StringVar(&prompt, "prompt", "", "preview which memories would inject for this prompt") + cmd.Flags().BoolVar(&verbose, "verbose", false, "show scored memory matches and retrieval reasons for prompt preview") + return cmd +} + +func newMemoryLoopModeCmd() *cobra.Command { + return &cobra.Command{ + Use: "mode [off|manual|auto]", + Short: "Set memory loop mode", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mode := memoryloop.Mode(strings.ToLower(strings.TrimSpace(args[0]))) + switch mode { + case memoryloop.ModeOff, memoryloop.ModeManual, memoryloop.ModeAuto: + return setMemoryLoopMode(cmd.Context(), cmd.OutOrStdout(), mode) + default: + return fmt.Errorf("invalid mode: %s", args[0]) + } + }, + } +} + +func newMemoryLoopPolicyCmd() *cobra.Command { + return &cobra.Command{ + Use: "policy [review|auto]", + Short: "Set memory loop activation policy", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + policy := memoryloop.ActivationPolicy(strings.ToLower(strings.TrimSpace(args[0]))) + switch policy { + case memoryloop.ActivationPolicyReview, memoryloop.ActivationPolicyAuto: + return setMemoryLoopPolicy(cmd.Context(), cmd.OutOrStdout(), policy) + default: + return fmt.Errorf("invalid activation policy: %s", args[0]) + } + }, + } +} + +func newMemoryLoopActivateCmd() *cobra.Command { + return &cobra.Command{ + Use: "activate ", + Short: "Activate a personal memory candidate", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runMemoryLoopActivate(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func newMemoryLoopPromoteCmd() *cobra.Command { + return &cobra.Command{ + Use: "promote ", + Short: "Promote a repo-scoped memory candidate to active", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runMemoryLoopPromote(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func newMemoryLoopSuppressCmd() *cobra.Command { + return &cobra.Command{ + Use: "suppress ", + Short: "Suppress a memory so it no longer injects", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runMemoryLoopSuppress(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func newMemoryLoopUnsuppressCmd() *cobra.Command { + return &cobra.Command{ + Use: "unsuppress ", + Short: "Return a suppressed memory to candidate state", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runMemoryLoopUnsuppress(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func newMemoryLoopArchiveCmd() *cobra.Command { + return &cobra.Command{ + Use: "archive ", + Short: "Archive a memory while preserving its history", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runMemoryLoopArchive(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func newMemoryLoopPruneCmd() *cobra.Command { + return &cobra.Command{ + Use: "prune", + Short: "Archive stale or ineffective generated memories", + RunE: func(cmd *cobra.Command, _ []string) error { + return runMemoryLoopPrune(cmd.Context(), cmd.OutOrStdout(), time.Now().UTC()) + }, + } +} + +func runMemoryLoopRefresh(ctx context.Context, w io.Writer, last int, scopeArg, branchArg string) error { + worktreeRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("not in a git repository: %w", err) + } + + cfg, cfgErr := LoadEntireSettings(ctx) + if cfgErr != nil { + return fmt.Errorf("load settings: %w", cfgErr) + } + + idb, err := insightsdb.Open(filepath.Join(worktreeRoot, paths.EntireDir, "insights.db")) + if err != nil { + return fmt.Errorf("open insights cache: %w", err) + } + defer func() { _ = idb.Close() }() + + renderMemoryLoopRefreshProgress(w, "Refreshing cache...") + refreshCacheIfStale(ctx, idb) //nolint:errcheck,gosec // non-fatal + if settings.IsSummarizeEnabled(ctx) { + renderMemoryLoopRefreshProgress(w, "Backfilling summaries...") + backfillSummaries(ctx, w, idb, last) + renderMemoryLoopRefreshProgress(w, "Backfilling facets...") + backfillFacets(ctx, idb, last) + } + + scope, err := resolveMemoryLoopScope(ctx, scopeArg, branchArg) + if err != nil { + return err + } + + renderMemoryLoopRefreshProgress(w, "Loading scoped sessions...") + rows, err := queryMemoryLoopRows(ctx, idb, scope, last) + if err != nil { + return fmt.Errorf("query sessions: %w", err) + } + + analysis := improve.AnalyzePatterns(sessionRowsToSummaries(rows)) + gen := memoryloop.Generator{Runner: &llmcli.Runner{}} + renderMemoryLoopRefreshProgress(w, "Distilling memories...") + records, usage, err := gen.Generate(ctx, memoryloop.GenerateInput{ + Analysis: analysis, + Sessions: rows, + SourceWindow: last, + MaxRecords: memoryloop.DefaultMaxRecords, + }) + if err != nil { + return fmt.Errorf("generate memory snapshot: %w", err) + } + + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + + memoryCfg := cfg.GetMemoryLoopConfig() + maxInjected := memoryCfg.MaxInjected + mode := memoryloop.Mode(memoryCfg.Mode) + activationPolicy := memoryloop.ActivationPolicy(memoryCfg.ActivationPolicy) + if state.Store != nil { + if state.Store.Mode != "" { + mode = state.Store.Mode + } + if state.Store.MaxInjected > 0 { + maxInjected = state.Store.MaxInjected + } + if state.Store.ActivationPolicy != "" { + activationPolicy = state.Store.ActivationPolicy + } + } + + now := time.Now().UTC() + scopeValue := scopeValue(scope) + renderMemoryLoopRefreshProgress(w, "Reconciling with existing memory history...") + reconcile := memoryloop.ReconcileGeneratedRecords( + existingMemoryRecords(state), + records, + scopeKindForRefresh(scope), + scopeValue, + activationPolicy, + now, + ) + reconcile.History.Scope = string(scope.Mode) + reconcile.History.SourceWindow = last + refreshHistory := append(existingRefreshHistory(state), reconcile.History) + reconciledRecords := memoryloop.DeriveOutcomes(reconcile.Records, rows, now) + + state.Store = &memoryloop.Store{ + Version: 1, + GeneratedAt: now, + SourceWindow: last, + Scope: string(scope.Mode), + ScopeValue: scopeValue, + Records: reconciledRecords, + Mode: mode, + ActivationPolicy: activationPolicy, + MaxInjected: maxInjected, + RefreshHistory: refreshHistory, + } + + renderMemoryLoopRefreshProgress(w, "Saving memory store...") + if err := memoryloop.SaveState(ctx, state); err != nil { + return fmt.Errorf("save memory-loop state: %w", err) + } + + activeCount, candidateCount, suppressedCount, archivedCount := countMemoryStatuses(reconciledRecords) + generatedTitles := make([]string, 0, len(records)) + for _, record := range records { + if title := strings.TrimSpace(record.Title); title != "" { + generatedTitles = append(generatedTitles, title) + } + } + renderMemoryLoopRefreshSummary(w, memoryLoopRefreshSummary{ + SessionCount: len(rows), + ScopeLabel: formatMemoryLoopScopeLabel(scope), + GeneratedCount: reconcile.History.GeneratedCount, + ActivatedCount: reconcile.History.ActivatedCount, + CandidateCount: reconcile.History.CandidateCount, + ActiveCount: activeCount, + StoredCandidateCount: candidateCount, + SuppressedCount: suppressedCount, + ArchivedCount: archivedCount, + GeneratedTitles: generatedTitles, + }) + if usage != nil { + renderUsageLine(w, usage) + } + + return nil +} + +func existingMemoryRecords(state *memoryloop.State) []memoryloop.MemoryRecord { + if state == nil || state.Store == nil { + return nil + } + return state.Store.Records +} + +func existingRefreshHistory(state *memoryloop.State) []memoryloop.RefreshHistory { + if state == nil || state.Store == nil { + return nil + } + return append([]memoryloop.RefreshHistory(nil), state.Store.RefreshHistory...) +} + +func renderMemoryLoopRefreshProgress(w io.Writer, line string) { + fmt.Fprintln(w, strings.TrimSpace(line)) +} + +func renderMemoryLoopRefreshSummary(w io.Writer, summary memoryLoopRefreshSummary) { + fmt.Fprintf(w, "Memory Loop refreshed from %d sessions\n", summary.SessionCount) + if summary.ScopeLabel != "" { + fmt.Fprintf(w, "Scope: %s\n", summary.ScopeLabel) + } + fmt.Fprintf( + w, + "This refresh: generated %d, activated %d, candidate %d\n", + summary.GeneratedCount, + summary.ActivatedCount, + summary.CandidateCount, + ) + fmt.Fprintf( + w, + "Stored memories: active %d, candidate %d, suppressed %d, archived %d\n", + summary.ActiveCount, + storedCandidateCount(summary), + summary.SuppressedCount, + summary.ArchivedCount, + ) + for _, title := range summary.GeneratedTitles { + fmt.Fprintf(w, " - %s\n", title) + } +} + +func formatMemoryLoopScopeLabel(scope memoryLoopScope) string { + label := string(scope.Mode) + if value := strings.TrimSpace(scopeValue(scope)); value != "" { + label = fmt.Sprintf("%s (%s)", label, value) + } + return label +} + +func storedCandidateCount(summary memoryLoopRefreshSummary) int { + if summary.StoredCandidateCount > 0 { + return summary.StoredCandidateCount + } + return summary.CandidateCount +} + +func scopeKindForRefresh(scope memoryLoopScope) memoryloop.ScopeKind { + switch scope.Mode { + case memoryLoopScopeMe: + return memoryloop.ScopeKindMe + case memoryLoopScopeRepo, memoryLoopScopeBranch: + return memoryloop.ScopeKindRepo + default: + return memoryloop.ScopeKindRepo + } +} + +func runMemoryLoopShow(ctx context.Context, w io.Writer, _ string) error { + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + + s := termstyle.New(w) + fmt.Fprintln(w, s.Render(s.Bold, "Entire Memory Loop")) + + if state.Snapshot == nil { + fmt.Fprintln(w, "No memory snapshot found. Run `entire memory-loop refresh` first.") + return nil + } + + snapshot := state.Snapshot + activeCount, candidateCount, suppressedCount, archivedCount := countMemoryStatuses(snapshot.Records) + if !snapshot.GeneratedAt.IsZero() { + fmt.Fprintf(w, "Last refresh: %s\n", snapshot.GeneratedAt.Format(time.RFC3339)) + } + if snapshot.Scope != "" { + fmt.Fprintf(w, "Scope: %s", snapshot.Scope) + if snapshot.ScopeValue != "" { + fmt.Fprintf(w, " (%s)", snapshot.ScopeValue) + } + fmt.Fprintln(w) + } + fmt.Fprintf(w, "Active memories: %d\n", activeCount) + fmt.Fprintf(w, "Candidate memories: %d\n", candidateCount) + fmt.Fprintf(w, "Suppressed memories: %d\n", suppressedCount) + fmt.Fprintf(w, "Archived memories: %d\n", archivedCount) + fmt.Fprintf(w, "Mode: %s\n", displayMemoryMode(snapshot.Mode)) + fmt.Fprintf(w, "Activation policy: %s\n", displayActivationPolicy(snapshot.ActivationPolicy)) + fmt.Fprintf(w, "Max injected: %d\n\n", snapshot.MaxInjected) + renderDetailedMemorySections(w, s, snapshot.Records) + renderRefreshHistorySection(w, s, snapshot.RefreshHistory) + renderRecentInjectionsSection(w, s, state.InjectionLogs) + + frequent := frequentMemoryTitles(snapshot, state.InjectionLogs) + if len(frequent) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, s.SectionRule("Most Injected")) + for _, item := range frequent[:min(5, len(frequent))] { + fmt.Fprintf(w, " - %s\n", item) + } + } + + return nil +} + +func runMemoryLoopStatus(ctx context.Context, w io.Writer, prompt string, verbose bool) error { + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + + s := termstyle.New(w) + fmt.Fprintln(w, s.Render(s.Bold, "Entire Memory Loop")) + + if state.Snapshot == nil { + fmt.Fprintln(w, "No memory snapshot found. Run `entire memory-loop refresh` first.") + return nil + } + + snapshot := state.Snapshot + activeCount, candidateCount, suppressedCount, archivedCount := countMemoryStatuses(snapshot.Records) + if !snapshot.GeneratedAt.IsZero() { + fmt.Fprintf(w, "Last refresh: %s\n", snapshot.GeneratedAt.Format(time.RFC3339)) + } + if snapshot.Scope != "" { + fmt.Fprintf(w, "Scope: %s", snapshot.Scope) + if snapshot.ScopeValue != "" { + fmt.Fprintf(w, " (%s)", snapshot.ScopeValue) + } + fmt.Fprintln(w) + } + fmt.Fprintf(w, "Mode: %s\n", displayMemoryMode(snapshot.Mode)) + fmt.Fprintf(w, "Activation policy: %s\n", displayActivationPolicy(snapshot.ActivationPolicy)) + fmt.Fprintf(w, "Max injected: %d\n", snapshot.MaxInjected) + fmt.Fprintf(w, "Active memories: %d\n", activeCount) + fmt.Fprintf(w, "Candidate memories: %d\n", candidateCount) + fmt.Fprintf(w, "Suppressed memories: %d\n", suppressedCount) + fmt.Fprintf(w, "Archived memories: %d\n", archivedCount) + + if prompt != "" { + fmt.Fprintln(w) + fmt.Fprintln(w, s.SectionRule("Prompt Preview")) + matches := memoryloop.SelectRelevant(*snapshot, prompt, time.Now().UTC()) + if len(matches) == 0 { + fmt.Fprintln(w, " No memories would inject for that prompt.") + } else { + fmt.Fprintln(w, indentBlock(memoryloop.FormatInjectionBlock(matches), " ")) + if verbose { + for _, match := range matches { + fmt.Fprintf( + w, + " - %s [%s] score=%d reason=%s scope=%s status=%s\n", + match.Record.ID, + match.Record.Kind, + match.Score, + match.Reason, + formatMemoryLoopRecordScope(match.Record), + displayMemoryStatus(match.Record.Status), + ) + } + } + } + } + + return nil +} + +func setMemoryLoopMode(ctx context.Context, w io.Writer, mode memoryloop.Mode) error { + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + if err := ensureMemoryLoopStore(ctx, state); err != nil { + return err + } + state.Store.Mode = mode + if err := memoryloop.SaveState(ctx, state); err != nil { + return fmt.Errorf("save memory-loop state: %w", err) + } + fmt.Fprintf(w, "Memory loop mode: %s\n", mode) + return nil +} + +func setMemoryLoopPolicy(ctx context.Context, w io.Writer, policy memoryloop.ActivationPolicy) error { + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + if err := ensureMemoryLoopStore(ctx, state); err != nil { + return err + } + state.Store.ActivationPolicy = policy + if err := memoryloop.SaveState(ctx, state); err != nil { + return fmt.Errorf("save memory-loop state: %w", err) + } + fmt.Fprintf(w, "Memory loop activation policy: %s\n", policy) + return nil +} + +func runMemoryLoopActivate(ctx context.Context, w io.Writer, id string) error { + return runMemoryLoopLifecycleAction(ctx, w, id, memoryloop.LifecycleActionActivate) +} + +func runMemoryLoopPromote(ctx context.Context, w io.Writer, id string) error { + return runMemoryLoopLifecycleAction(ctx, w, id, memoryloop.LifecycleActionPromote) +} + +func runMemoryLoopSuppress(ctx context.Context, w io.Writer, id string) error { + return runMemoryLoopLifecycleAction(ctx, w, id, memoryloop.LifecycleActionSuppress) +} + +func runMemoryLoopUnsuppress(ctx context.Context, w io.Writer, id string) error { + return runMemoryLoopLifecycleAction(ctx, w, id, memoryloop.LifecycleActionUnsuppress) +} + +func runMemoryLoopArchive(ctx context.Context, w io.Writer, id string) error { + return runMemoryLoopLifecycleAction(ctx, w, id, memoryloop.LifecycleActionArchive) +} + +func runMemoryLoopPrune(ctx context.Context, w io.Writer, now time.Time) error { + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + if state.Store == nil { + return errors.New("no memory store found; run `entire memory-loop refresh` first") + } + + records, result := memoryloop.PruneRecords(state.Store.Records, now) + state.Store.Records = records + if err := memoryloop.SaveState(ctx, state); err != nil { + return fmt.Errorf("save memory-loop state: %w", err) + } + + fmt.Fprintf(w, "Pruned memories: archived %d\n", result.ArchivedCount) + return nil +} + +func runMemoryLoopAdd(ctx context.Context, w io.Writer, opts memoryLoopAddOptions) error { + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + if err := ensureMemoryLoopStore(ctx, state); err != nil { + return err + } + + kind, err := parseMemoryLoopKind(opts.Kind) + if err != nil { + return err + } + scopeKind, scopeValue, ownerEmail, err := resolveAddScope(ctx, opts.Scope) + if err != nil { + return err + } + + records, added, err := memoryloop.AddManualRecord(state.Store.Records, memoryloop.ManualRecordInput{ + Kind: kind, + Title: opts.Title, + Body: opts.Body, + ScopeKind: scopeKind, + ScopeValue: scopeValue, + OwnerEmail: ownerEmail, + }, time.Now().UTC()) + if err != nil { + return fmt.Errorf("add manual memory: %w", err) + } + + state.Store.Records = records + if err := memoryloop.SaveState(ctx, state); err != nil { + return fmt.Errorf("save memory-loop state: %w", err) + } + + fmt.Fprintf(w, "Added memory: %s\n", added.ID) + return nil +} + +func runMemoryLoopLifecycleAction(ctx context.Context, w io.Writer, id string, action memoryloop.LifecycleAction) error { + state, err := memoryloop.LoadState(ctx) + if err != nil { + return fmt.Errorf("load memory-loop state: %w", err) + } + if state.Store == nil { + return errors.New("no memory store found; run `entire memory-loop refresh` first") + } + + records, _, err := memoryloop.TransitionRecordLifecycle(state.Store.Records, id, action, time.Now().UTC()) + if err != nil { + return fmt.Errorf("transition memory lifecycle: %w", err) + } + state.Store.Records = records + if err := memoryloop.SaveState(ctx, state); err != nil { + return fmt.Errorf("save memory-loop state: %w", err) + } + + fmt.Fprintf(w, "%s memory: %s\n", lifecycleActionLabel(action), id) + return nil +} + +func ensureMemoryLoopStore(ctx context.Context, state *memoryloop.State) error { + if state.Store != nil { + return nil + } + + cfg, err := LoadEntireSettings(ctx) + if err == nil { + memoryCfg := cfg.GetMemoryLoopConfig() + state.Store = &memoryloop.Store{ + Version: 1, + Mode: memoryloop.Mode(memoryCfg.Mode), + ActivationPolicy: memoryloop.ActivationPolicy(memoryCfg.ActivationPolicy), + MaxInjected: memoryCfg.MaxInjected, + } + return nil + } + return fmt.Errorf("load settings: %w", err) +} + +func lifecycleActionLabel(action memoryloop.LifecycleAction) string { + switch action { + case memoryloop.LifecycleActionActivate: + return "Activated" + case memoryloop.LifecycleActionPromote: + return "Promoted" + case memoryloop.LifecycleActionSuppress: + return "Suppressed" + case memoryloop.LifecycleActionUnsuppress: + return "Unsuppressed" + case memoryloop.LifecycleActionArchive: + return "Archived" + default: + return "Updated" + } +} + +func parseMemoryLoopKind(raw string) (memoryloop.Kind, error) { + kind := memoryloop.Kind(strings.ToLower(strings.TrimSpace(raw))) + switch kind { + case memoryloop.KindRepoRule, + memoryloop.KindWorkflowRule, + memoryloop.KindAgentInstruction, + memoryloop.KindSkillPatch, + memoryloop.KindAntiPattern: + return kind, nil + default: + return "", fmt.Errorf("invalid memory kind: %s", raw) + } +} + +func resolveAddScope(ctx context.Context, rawScope string) (memoryloop.ScopeKind, string, string, error) { + switch memoryLoopScopeMode(strings.ToLower(strings.TrimSpace(rawScope))) { + case "", memoryLoopScopeMe: + author, err := GetGitAuthor(ctx) + if err != nil { + return "", "", "", fmt.Errorf("resolve git author: %w", err) + } + return memoryloop.ScopeKindMe, author.Email, author.Email, nil + case memoryLoopScopeRepo: + return memoryloop.ScopeKindRepo, "", "", nil + case memoryLoopScopeBranch: + return "", "", "", fmt.Errorf("invalid memory scope: %s", rawScope) + default: + return "", "", "", fmt.Errorf("invalid memory scope: %s", rawScope) + } +} + +func recentLogs(logs []memoryloop.InjectionLog, limit int) []memoryloop.InjectionLog { + if len(logs) <= limit { + return logs + } + return logs[len(logs)-limit:] +} + +func renderDetailedMemorySections(w io.Writer, s termstyle.Styles, records []memoryloop.MemoryRecord) { + renderMemorySection(w, s, "Active Memories", filterMemoryRecordsByStatus(records, memoryloop.StatusActive)) + renderMemorySection(w, s, "Candidate Memories", filterMemoryRecordsByStatus(records, memoryloop.StatusCandidate)) + renderMemorySection(w, s, "Suppressed Memories", filterMemoryRecordsByStatus(records, memoryloop.StatusSuppressed)) + renderMemorySection(w, s, "Archived Memories", filterMemoryRecordsByStatus(records, memoryloop.StatusArchived)) +} + +func renderMemorySection(w io.Writer, s termstyle.Styles, title string, records []memoryloop.MemoryRecord) { + fmt.Fprintln(w, s.SectionRule(title)) + if len(records) == 0 { + fmt.Fprintln(w, " No memories.") + fmt.Fprintln(w) + return + } + for _, record := range records { + fmt.Fprintf(w, " - %s [%s] (%s)\n", record.Title, record.Kind, displayMemoryStatus(record.Status)) + if record.Body != "" { + fmt.Fprintf(w, " %s\n", record.Body) + } + } + fmt.Fprintln(w) +} + +func renderRefreshHistorySection(w io.Writer, s termstyle.Styles, history []memoryloop.RefreshHistory) { + fmt.Fprintln(w, s.SectionRule("Recent Refreshes")) + if len(history) == 0 { + fmt.Fprintln(w, " No refresh history recorded yet.") + fmt.Fprintln(w) + return + } + for _, item := range recentRefreshHistory(history, 10) { + fmt.Fprintf( + w, + " - %s %s%s generated %d, activated %d, candidate %d\n", + item.At.Format(time.RFC3339), + item.Scope, + formatOptionalScopeValue(item.ScopeValue), + item.GeneratedCount, + item.ActivatedCount, + item.CandidateCount, + ) + } + fmt.Fprintln(w) +} + +func renderRecentInjectionsSection(w io.Writer, s termstyle.Styles, logs []memoryloop.InjectionLog) { + fmt.Fprintln(w, s.SectionRule("Recent Injections")) + if len(logs) == 0 { + fmt.Fprintln(w, " No injections recorded yet.") + return + } + for _, entry := range recentLogs(logs, 10) { + fmt.Fprintf(w, " - %s %s\n", entry.InjectedAt.Format(time.RFC3339), entry.SessionID) + if entry.PromptPreview != "" { + fmt.Fprintf(w, " %s\n", entry.PromptPreview) + } + } +} + +func filterMemoryRecordsByStatus(records []memoryloop.MemoryRecord, status memoryloop.Status) []memoryloop.MemoryRecord { + filtered := make([]memoryloop.MemoryRecord, 0, len(records)) + for _, record := range records { + recordStatus := record.Status + if recordStatus == "" { + recordStatus = memoryloop.StatusActive + } + if recordStatus == status { + filtered = append(filtered, record) + } + } + return filtered +} + +func recentRefreshHistory(history []memoryloop.RefreshHistory, limit int) []memoryloop.RefreshHistory { + if len(history) <= limit { + return history + } + return history[len(history)-limit:] +} + +func countMemoryStatuses(records []memoryloop.MemoryRecord) (active, candidate, suppressed, archived int) { + for _, record := range records { + switch record.Status { + case memoryloop.StatusActive: + active++ + case memoryloop.StatusCandidate: + candidate++ + case memoryloop.StatusSuppressed: + suppressed++ + case memoryloop.StatusArchived: + archived++ + default: + active++ + } + } + return active, candidate, suppressed, archived +} + +func mustMarkFlagRequired(cmd *cobra.Command, name string) { + if err := cmd.MarkFlagRequired(name); err != nil { + panic(fmt.Sprintf("mark %q required: %v", name, err)) + } +} + +func displayMemoryStatus(status memoryloop.Status) string { + if status == "" { + return string(memoryloop.StatusActive) + } + return string(status) +} + +func displayMemoryMode(mode memoryloop.Mode) string { + if mode == "" { + return string(memoryloop.ModeOff) + } + return string(mode) +} + +func displayActivationPolicy(policy memoryloop.ActivationPolicy) string { + if policy == "" { + return string(memoryloop.ActivationPolicyReview) + } + return string(policy) +} + +func frequentMemoryTitles(snapshot *memoryloop.Snapshot, logs []memoryloop.InjectionLog) []string { + if snapshot == nil { + return nil + } + titleByID := make(map[string]string, len(snapshot.Records)) + for _, record := range snapshot.Records { + titleByID[record.ID] = record.Title + } + counts := make(map[string]int) + for _, log := range logs { + for _, id := range log.InjectedMemoryIDs { + counts[id]++ + } + } + + type pair struct { + title string + count int + } + pairs := make([]pair, 0, len(counts)) + for id, count := range counts { + pairs = append(pairs, pair{title: titleByID[id], count: count}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].count != pairs[j].count { + return pairs[i].count > pairs[j].count + } + return pairs[i].title < pairs[j].title + }) + + out := make([]string, 0, len(pairs)) + for _, item := range pairs { + if item.title == "" { + continue + } + out = append(out, fmt.Sprintf("%s (%d)", item.title, item.count)) + } + return out +} + +func indentBlock(text, indent string) string { + lines := strings.Split(strings.TrimSpace(text), "\n") + for i, line := range lines { + lines[i] = indent + line + } + return strings.Join(lines, "\n") +} + +func formatOptionalScopeValue(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return fmt.Sprintf(" (%s)", strings.TrimSpace(value)) +} + +func formatMemoryLoopRecordScope(record memoryloop.MemoryRecord) string { + scope := string(record.ScopeKind) + if scope == "" { + scope = string(memoryloop.ScopeKindMe) + } + if strings.TrimSpace(record.ScopeValue) == "" { + return scope + } + return fmt.Sprintf("%s(%s)", scope, strings.TrimSpace(record.ScopeValue)) +} + +func resolveMemoryLoopScope(ctx context.Context, scopeArg, branchArg string) (memoryLoopScope, error) { + scope := memoryLoopScope{Mode: memoryLoopScopeMode(strings.ToLower(strings.TrimSpace(scopeArg)))} + if scope.Mode == "" { + scope.Mode = memoryLoopScopeRepo + } + + switch scope.Mode { + case memoryLoopScopeRepo: + return scope, nil + case memoryLoopScopeBranch: + if branch := strings.TrimSpace(branchArg); branch != "" { + scope.Branch = branch + return scope, nil + } + repo, err := openRepository(ctx) + if err != nil { + return memoryLoopScope{}, fmt.Errorf("open git repository: %w", err) + } + branch := strategy.GetCurrentBranchName(repo) + if branch == "" { + return memoryLoopScope{}, errors.New("current HEAD is detached; pass --branch when using --scope branch") + } + scope.Branch = branch + return scope, nil + case memoryLoopScopeMe: + repo, err := openRepository(ctx) + if err != nil { + return memoryLoopScope{}, fmt.Errorf("open git repository: %w", err) + } + _, email := checkpoint.GetGitAuthorFromRepo(repo) + if email == "" { + return memoryLoopScope{}, errors.New("could not determine git user.email for --scope me") + } + scope.OwnerEmail = email + return scope, nil + default: + return memoryLoopScope{}, fmt.Errorf("invalid scope %q: must be repo, branch, or me", scopeArg) + } +} + +func queryMemoryLoopRows(ctx context.Context, idb *insightsdb.InsightsDB, scope memoryLoopScope, last int) ([]insightsdb.SessionRow, error) { + switch scope.Mode { + case memoryLoopScopeRepo: + rows, err := idb.QueryLastNSessions(ctx, last) + if err != nil { + return nil, fmt.Errorf("query repo-scoped sessions: %w", err) + } + return rows, nil + case memoryLoopScopeBranch: + rows, err := idb.QueryByBranch(ctx, scope.Branch, last) + if err != nil { + return nil, fmt.Errorf("query branch-scoped sessions: %w", err) + } + return rows, nil + case memoryLoopScopeMe: + rows, err := idb.QueryByOwnerEmail(ctx, scope.OwnerEmail, last) + if err != nil { + return nil, fmt.Errorf("query owner-scoped sessions: %w", err) + } + return rows, nil + default: + return nil, fmt.Errorf("unsupported scope mode %q", scope.Mode) + } +} + +func filterMemoryLoopRows(rows []insightsdb.SessionRow, scope memoryLoopScope, limit int) ([]insightsdb.SessionRow, error) { + filtered := make([]insightsdb.SessionRow, 0, len(rows)) + for _, row := range rows { + switch scope.Mode { + case memoryLoopScopeRepo: + filtered = append(filtered, row) + case memoryLoopScopeBranch: + if row.Branch == scope.Branch { + filtered = append(filtered, row) + } + case memoryLoopScopeMe: + if strings.EqualFold(row.OwnerEmail, scope.OwnerEmail) { + filtered = append(filtered, row) + } + default: + return nil, fmt.Errorf("unsupported scope mode %q", scope.Mode) + } + } + + sort.SliceStable(filtered, func(i, j int) bool { + return filtered[i].CreatedAt.After(filtered[j].CreatedAt) + }) + if limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + return filtered, nil +} + +func scopeValue(scope memoryLoopScope) string { + switch scope.Mode { + case memoryLoopScopeRepo: + return "" + case memoryLoopScopeBranch: + return scope.Branch + case memoryLoopScopeMe: + return scope.OwnerEmail + default: + return "" + } +} diff --git a/cmd/entire/cli/memory_loop_cmd_test.go b/cmd/entire/cli/memory_loop_cmd_test.go new file mode 100644 index 000000000..b7aa3f82b --- /dev/null +++ b/cmd/entire/cli/memory_loop_cmd_test.go @@ -0,0 +1,699 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/stretchr/testify/require" +) + +func TestFilterMemoryLoopRows_ScopeMe(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + rows := []insightsdb.SessionRow{ + {SessionID: "mine-newer", OwnerEmail: "me@example.com", Branch: "main", CreatedAt: now.Add(-time.Hour)}, + {SessionID: "coworker", OwnerEmail: "teammate@example.com", Branch: "main", CreatedAt: now.Add(-2 * time.Hour)}, + {SessionID: "mine-older", OwnerEmail: "me@example.com", Branch: "feature", CreatedAt: now.Add(-3 * time.Hour)}, + } + + filtered, err := filterMemoryLoopRows(rows, memoryLoopScope{ + Mode: memoryLoopScopeMe, + OwnerEmail: "me@example.com", + }, 10) + require.NoError(t, err) + require.Len(t, filtered, 2) + require.Equal(t, "mine-newer", filtered[0].SessionID) + require.Equal(t, "mine-older", filtered[1].SessionID) +} + +func TestFilterMemoryLoopRows_ScopeBranch(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + rows := []insightsdb.SessionRow{ + {SessionID: "main-newer", Branch: "main", CreatedAt: now.Add(-time.Hour)}, + {SessionID: "feature", Branch: "feature/owner", CreatedAt: now.Add(-2 * time.Hour)}, + {SessionID: "main-older", Branch: "main", CreatedAt: now.Add(-3 * time.Hour)}, + } + + filtered, err := filterMemoryLoopRows(rows, memoryLoopScope{ + Mode: memoryLoopScopeBranch, + Branch: "main", + }, 10) + require.NoError(t, err) + require.Len(t, filtered, 2) + require.Equal(t, "main-newer", filtered[0].SessionID) + require.Equal(t, "main-older", filtered[1].SessionID) +} + +func TestRunMemoryLoopShow_GroupsDetailedInventory(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + state := &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + GeneratedAt: now, + SourceWindow: 20, + Scope: "me", + ScopeValue: "test@example.com", + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyAuto, + Records: []memoryloop.MemoryRecord{ + {ID: "active", Title: "Active memory", Body: "body", Kind: memoryloop.KindRepoRule, Status: memoryloop.StatusActive}, + {ID: "candidate", Title: "Candidate memory", Body: "body", Kind: memoryloop.KindRepoRule, Status: memoryloop.StatusCandidate}, + {ID: "suppressed", Title: "Suppressed memory", Body: "body", Kind: memoryloop.KindRepoRule, Status: memoryloop.StatusSuppressed}, + {ID: "archived", Title: "Archived memory", Body: "body", Kind: memoryloop.KindRepoRule, Status: memoryloop.StatusArchived}, + }, + InjectionEnabled: true, + MaxInjected: 3, + RefreshHistory: []memoryloop.RefreshHistory{ + {At: now, Scope: "me", ScopeValue: "test@example.com", GeneratedCount: 2, ActivatedCount: 1, CandidateCount: 1}, + }, + }, + } + require.NoError(t, memoryloop.SaveState(context.Background(), state)) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopShow(context.Background(), &buf, "")) + + out := buf.String() + require.Contains(t, out, "Mode: manual") + require.Contains(t, out, "Activation policy: auto") + require.Contains(t, out, "Active Memories") + require.Contains(t, out, "Candidate Memories") + require.Contains(t, out, "Suppressed Memories") + require.Contains(t, out, "Archived Memories") + require.Contains(t, out, "Active memory [repo_rule] (active)") + require.Contains(t, out, "Candidate memory [repo_rule] (candidate)") + require.Contains(t, out, "Suppressed memory [repo_rule] (suppressed)") + require.Contains(t, out, "Archived memory [repo_rule] (archived)") + require.Contains(t, out, "Recent Refreshes") + require.Contains(t, out, "generated 2, activated 1, candidate 1") + require.Contains(t, out, "Recent Injections") +} + +func TestRunMemoryLoopStatus_IsConciseAndSupportsVerbosePromptPreview(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + state := &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + GeneratedAt: now, + Scope: "me", + ScopeValue: "test@example.com", + Mode: memoryloop.ModeAuto, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "lint", + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Kind: memoryloop.KindRepoRule, + ScopeKind: memoryloop.ScopeKindMe, + ScopeValue: "test@example.com", + Status: memoryloop.StatusActive, + }, + { + ID: "candidate-skill", + Title: "Tighten the project skill", + Body: "Add the missing retry step.", + Kind: memoryloop.KindSkillPatch, + ScopeKind: memoryloop.ScopeKindMe, + Status: memoryloop.StatusCandidate, + }, + }, + }, + } + require.NoError(t, memoryloop.SaveState(context.Background(), state)) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopStatus(context.Background(), &buf, "fix the lint failure", true)) + + out := buf.String() + require.Contains(t, out, "Last refresh: 2026-03-26T12:00:00Z") + require.Contains(t, out, "Mode: auto") + require.Contains(t, out, "Activation policy: review") + require.Contains(t, out, "Active memories: 1") + require.Contains(t, out, "Candidate memories: 1") + require.NotContains(t, out, "Recent Injections") + require.NotContains(t, out, "Active Memories") + require.Contains(t, out, "Prompt Preview") + require.Contains(t, out, "Memory For This Repo") + require.Contains(t, out, "lint [repo_rule] score=") + require.Contains(t, out, "reason=") + require.Contains(t, out, "scope=me(test@example.com)") + require.Contains(t, out, "status=active") +} + +func TestSetMemoryLoopMode_PersistsAuthoritativeMode(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + state := &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + GeneratedAt: time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC), + SourceWindow: 20, + Mode: memoryloop.ModeAuto, + ActivationPolicy: memoryloop.ActivationPolicyReview, + InjectionEnabled: true, + MaxInjected: 3, + }, + } + require.NoError(t, memoryloop.SaveState(context.Background(), state)) + + var buf bytes.Buffer + require.NoError(t, setMemoryLoopMode(context.Background(), &buf, memoryloop.ModeOff)) + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.ModeOff, loaded.Store.Mode) + require.False(t, loaded.Store.InjectionEnabled) + require.Contains(t, buf.String(), "Memory loop mode: off") +} + +func TestSetMemoryLoopMode_CreatesStoreBeforeRefresh(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + var buf bytes.Buffer + require.NoError(t, setMemoryLoopMode(context.Background(), &buf, memoryloop.ModeManual)) + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.NotNil(t, loaded.Store) + require.Equal(t, memoryloop.ModeManual, loaded.Store.Mode) + require.Equal(t, memoryloop.ActivationPolicyReview, loaded.Store.ActivationPolicy) + require.Equal(t, memoryloop.DefaultMaxInjected, loaded.Store.MaxInjected) + require.Contains(t, buf.String(), "Memory loop mode: manual") +} + +func TestSetMemoryLoopPolicy_PersistsActivationPolicy(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + state := &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + GeneratedAt: time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC), + SourceWindow: 20, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + }, + } + require.NoError(t, memoryloop.SaveState(context.Background(), state)) + + var buf bytes.Buffer + require.NoError(t, setMemoryLoopPolicy(context.Background(), &buf, memoryloop.ActivationPolicyAuto)) + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.ActivationPolicyAuto, loaded.Store.ActivationPolicy) + require.Contains(t, buf.String(), "Memory loop activation policy: auto") +} + +func TestSetMemoryLoopPolicy_CreatesStoreBeforeRefresh(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + var buf bytes.Buffer + require.NoError(t, setMemoryLoopPolicy(context.Background(), &buf, memoryloop.ActivationPolicyAuto)) + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.NotNil(t, loaded.Store) + require.Equal(t, memoryloop.ModeOff, loaded.Store.Mode) + require.Equal(t, memoryloop.ActivationPolicyAuto, loaded.Store.ActivationPolicy) + require.Equal(t, memoryloop.DefaultMaxInjected, loaded.Store.MaxInjected) + require.Contains(t, buf.String(), "Memory loop activation policy: auto") +} + +func TestSetMemoryLoopMode_InvalidSettingsReturnsError(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "memory_loop": { + "mode": "manuall" + } +}`), 0o644)) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + var buf bytes.Buffer + err := setMemoryLoopMode(context.Background(), &buf, memoryloop.ModeManual) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid memory_loop.mode") + + loaded, loadErr := memoryloop.LoadState(context.Background()) + require.NoError(t, loadErr) + require.Nil(t, loaded.Store) +} + +func TestSetMemoryLoopPolicy_InvalidSettingsReturnsError(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "memory_loop": { + "activation_policy": "autoreview" + } +}`), 0o644)) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + var buf bytes.Buffer + err := setMemoryLoopPolicy(context.Background(), &buf, memoryloop.ActivationPolicyAuto) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid memory_loop.activation_policy") + + loaded, loadErr := memoryloop.LoadState(context.Background()) + require.NoError(t, loadErr) + require.Nil(t, loaded.Store) +} + +func TestRunMemoryLoopRefresh_InvalidSettingsReturnsError(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "memory_loop": { + "activation_policy": "autoreview" + } +}`), 0o644)) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + var buf bytes.Buffer + err := runMemoryLoopRefresh(context.Background(), &buf, 10, "repo", "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid memory_loop.activation_policy") +} + +func TestRenderMemoryLoopRefreshProgress(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + renderMemoryLoopRefreshProgress(&buf, "Refreshing cache...") + renderMemoryLoopRefreshProgress(&buf, "Loading scoped sessions...") + + require.Equal(t, "Refreshing cache...\nLoading scoped sessions...\n", buf.String()) +} + +func TestRenderMemoryLoopRefreshSummary(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + renderMemoryLoopRefreshSummary(&buf, memoryLoopRefreshSummary{ + SessionCount: 12, + ScopeLabel: "me (me@example.com)", + GeneratedCount: 5, + ActivatedCount: 2, + CandidateCount: 3, + ActiveCount: 4, + SuppressedCount: 1, + ArchivedCount: 2, + GeneratedTitles: []string{"Run lint before finishing", "Tighten the project skill"}, + }) + + out := buf.String() + require.Contains(t, out, "Memory Loop refreshed from 12 sessions") + require.Contains(t, out, "Scope: me (me@example.com)") + require.Contains(t, out, "This refresh: generated 5, activated 2, candidate 3") + require.Contains(t, out, "Stored memories: active 4, candidate 3, suppressed 1, archived 2") + require.Contains(t, out, " - Run lint before finishing") + require.Contains(t, out, " - Tighten the project skill") +} + +func TestRunMemoryLoopActivate_UpdatesPersonalCandidate(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "candidate-skill", + Title: "Tighten the project skill", + Kind: memoryloop.KindSkillPatch, + ScopeKind: memoryloop.ScopeKindMe, + Status: memoryloop.StatusCandidate, + }, + }, + }, + })) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopActivate(context.Background(), &buf, "candidate-skill")) + require.Contains(t, buf.String(), "Activated memory: candidate-skill") + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.StatusActive, loaded.Store.Records[0].Status) +} + +func TestRunMemoryLoopActivate_RejectsRepoCandidate(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "repo-candidate", + Title: "Keep generated repo memories pending", + Kind: memoryloop.KindRepoRule, + ScopeKind: memoryloop.ScopeKindRepo, + ScopeValue: "main", + Status: memoryloop.StatusCandidate, + }, + }, + }, + })) + + var buf bytes.Buffer + err := runMemoryLoopActivate(context.Background(), &buf, "repo-candidate") + require.Error(t, err) + require.Contains(t, err.Error(), "promote") +} + +func TestRunMemoryLoopPromote_UpdatesRepoCandidate(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "repo-candidate", + Title: "Keep generated repo memories pending", + Kind: memoryloop.KindRepoRule, + ScopeKind: memoryloop.ScopeKindRepo, + ScopeValue: "main", + Status: memoryloop.StatusCandidate, + }, + }, + }, + })) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopPromote(context.Background(), &buf, "repo-candidate")) + require.Contains(t, buf.String(), "Promoted memory: repo-candidate") + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.StatusActive, loaded.Store.Records[0].Status) +} + +func TestRunMemoryLoopSuppress_UpdatesRecord(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "active-lint", + Title: "Run lint before finishing", + Kind: memoryloop.KindRepoRule, + ScopeKind: memoryloop.ScopeKindMe, + Status: memoryloop.StatusActive, + }, + }, + }, + })) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopSuppress(context.Background(), &buf, "active-lint")) + require.Contains(t, buf.String(), "Suppressed memory: active-lint") + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.StatusSuppressed, loaded.Store.Records[0].Status) +} + +func TestRunMemoryLoopUnsuppress_UpdatesRecord(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "suppressed-lint", + Title: "Run lint before finishing", + Kind: memoryloop.KindRepoRule, + ScopeKind: memoryloop.ScopeKindMe, + Status: memoryloop.StatusSuppressed, + }, + }, + }, + })) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopUnsuppress(context.Background(), &buf, "suppressed-lint")) + require.Contains(t, buf.String(), "Unsuppressed memory: suppressed-lint") + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.StatusCandidate, loaded.Store.Records[0].Status) +} + +func TestRunMemoryLoopArchive_UpdatesRecord(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "active-lint", + Title: "Run lint before finishing", + Kind: memoryloop.KindRepoRule, + ScopeKind: memoryloop.ScopeKindMe, + Status: memoryloop.StatusActive, + }, + }, + }, + })) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopArchive(context.Background(), &buf, "active-lint")) + require.Contains(t, buf.String(), "Archived memory: active-lint") + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.StatusArchived, loaded.Store.Records[0].Status) +} + +func TestRunMemoryLoopAdd_AddsPersonalManualMemory(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopAdd(context.Background(), &buf, memoryLoopAddOptions{ + Kind: string(memoryloop.KindRepoRule), + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Scope: "me", + })) + require.Contains(t, buf.String(), "Added memory:") + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Len(t, loaded.Store.Records, 1) + require.Equal(t, memoryloop.OriginManual, loaded.Store.Records[0].Origin) + require.Equal(t, memoryloop.StatusActive, loaded.Store.Records[0].Status) + require.Equal(t, memoryloop.ScopeKindMe, loaded.Store.Records[0].ScopeKind) + require.Equal(t, "test@example.com", loaded.Store.Records[0].OwnerEmail) +} + +func TestRunMemoryLoopAdd_AddsRepoScopedManualMemory(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopAdd(context.Background(), &buf, memoryLoopAddOptions{ + Kind: string(memoryloop.KindWorkflowRule), + Title: "Keep commit subjects concise", + Body: "Use short imperative commit subjects.", + Scope: "repo", + })) + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Len(t, loaded.Store.Records, 1) + require.Equal(t, memoryloop.ScopeKindRepo, loaded.Store.Records[0].ScopeKind) + require.Empty(t, loaded.Store.Records[0].ScopeValue) + require.Equal(t, memoryloop.OriginManual, loaded.Store.Records[0].Origin) +} + +func TestRunMemoryLoopPrune_ArchivesEligibleGeneratedMemories(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + require.NoError(t, memoryloop.SaveState(context.Background(), &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + Mode: memoryloop.ModeManual, + ActivationPolicy: memoryloop.ActivationPolicyReview, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "stale-candidate", + Title: "Pending lint rule", + Kind: memoryloop.KindRepoRule, + Status: memoryloop.StatusCandidate, + Origin: memoryloop.OriginGenerated, + CreatedAt: now.Add(-31 * 24 * time.Hour), + UpdatedAt: now.Add(-31 * 24 * time.Hour), + }, + { + ID: "manual-memory", + Title: "Personal preference", + Kind: memoryloop.KindWorkflowRule, + Status: memoryloop.StatusActive, + Origin: memoryloop.OriginManual, + CreatedAt: now.Add(-90 * 24 * time.Hour), + UpdatedAt: now.Add(-90 * 24 * time.Hour), + }, + }, + }, + })) + + var buf bytes.Buffer + require.NoError(t, runMemoryLoopPrune(context.Background(), &buf, now)) + + out := buf.String() + require.Contains(t, out, "Pruned memories: archived 1") + + loaded, err := memoryloop.LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, memoryloop.StatusArchived, loaded.Store.Records[0].Status) + require.Equal(t, memoryloop.StatusActive, loaded.Store.Records[1].Status) +} diff --git a/cmd/entire/cli/memory_loop_settings_test.go b/cmd/entire/cli/memory_loop_settings_test.go new file mode 100644 index 000000000..066619190 --- /dev/null +++ b/cmd/entire/cli/memory_loop_settings_test.go @@ -0,0 +1,160 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/stretchr/testify/require" +) + +func TestLoadEntireSettings_MemoryLoopConfig(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "enabled": true, + "memory_loop": { + "enabled": true, + "claude_injection_enabled": false, + "max_injected": 4, + "default_refresh_window": 12 + } +}`), 0o644)) + t.Chdir(tmpDir) + + loaded, err := LoadEntireSettings(context.Background()) + require.NoError(t, err) + cfg := loaded.GetMemoryLoopConfig() + require.True(t, cfg.Enabled) + require.Equal(t, "manual", cfg.Mode) + require.Equal(t, "review", cfg.ActivationPolicy) + require.Equal(t, 4, cfg.MaxInjected) + require.Equal(t, 12, cfg.DefaultRefreshWindow) +} + +func TestLoadEntireSettings_MemoryLoopLegacyEnabledAndInjectionMapping(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "enabled": true, + "memory_loop": { + "enabled": true, + "claude_injection_enabled": true + } +}`), 0o644)) + t.Chdir(tmpDir) + + loaded, err := LoadEntireSettings(context.Background()) + require.NoError(t, err) + cfg := loaded.GetMemoryLoopConfig() + require.True(t, cfg.Enabled) + require.Equal(t, "auto", cfg.Mode) +} + +func TestLoadEntireSettings_MemoryLoopModeAndPolicy(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "enabled": true, + "memory_loop": { + "enabled": true, + "mode": "manual", + "activation_policy": "auto", + "max_injected": 7 + } +}`), 0o644)) + t.Chdir(tmpDir) + + loaded, err := LoadEntireSettings(context.Background()) + require.NoError(t, err) + cfg := loaded.GetMemoryLoopConfig() + require.True(t, cfg.Enabled) + require.Equal(t, "manual", cfg.Mode) + require.Equal(t, "auto", cfg.ActivationPolicy) + require.Equal(t, 7, cfg.MaxInjected) +} + +func TestLoadEntireSettings_MemoryLoopExplicitModeOverridesLegacyEnabled(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "enabled": true, + "memory_loop": { + "enabled": false, + "mode": "auto" + } +}`), 0o644)) + t.Chdir(tmpDir) + + loaded, err := LoadEntireSettings(context.Background()) + require.NoError(t, err) + cfg := loaded.GetMemoryLoopConfig() + require.Equal(t, "auto", cfg.Mode) +} + +func TestLoadEntireSettings_MemoryLoopLocalOverrideMergesFields(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "enabled": true, + "memory_loop": { + "mode": "manual", + "activation_policy": "auto", + "max_injected": 3 + } +}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsLocalFile), []byte(`{ + "memory_loop": { + "max_injected": 9 + } +}`), 0o644)) + t.Chdir(tmpDir) + + loaded, err := LoadEntireSettings(context.Background()) + require.NoError(t, err) + cfg := loaded.GetMemoryLoopConfig() + require.Equal(t, "manual", cfg.Mode) + require.Equal(t, "auto", cfg.ActivationPolicy) + require.Equal(t, 9, cfg.MaxInjected) +} + +func TestLoadEntireSettings_InvalidMemoryLoopMode(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "enabled": true, + "memory_loop": { + "mode": "sideways" + } +}`), 0o644)) + t.Chdir(tmpDir) + + _, err := LoadEntireSettings(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid memory_loop.mode") +} + +func TestLoadEntireSettings_InvalidMemoryLoopActivationPolicy(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, settings.EntireSettingsFile), []byte(`{ + "enabled": true, + "memory_loop": { + "activation_policy": "sometimes" + } +}`), 0o644)) + t.Chdir(tmpDir) + + _, err := LoadEntireSettings(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid memory_loop.activation_policy") +} diff --git a/cmd/entire/cli/memoryloop/generator.go b/cmd/entire/cli/memoryloop/generator.go new file mode 100644 index 000000000..945fed4aa --- /dev/null +++ b/cmd/entire/cli/memoryloop/generator.go @@ -0,0 +1,246 @@ +package memoryloop + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/improve" + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/llmcli" +) + +const defaultMemorySlug = "memory" + +const DefaultMaxRecords = 20 + +type Generator struct { + Runner *llmcli.Runner +} + +type GenerateInput struct { + Analysis improve.PatternAnalysis + Sessions []insightsdb.SessionRow + SourceWindow int + MaxRecords int +} + +type generateResponse struct { + Records []generateRecord `json:"records"` +} + +type generateRecord struct { + Kind Kind `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Why string `json:"why"` + Evidence []string `json:"evidence"` + SourceSessionIDs []string `json:"source_session_ids"` + Confidence string `json:"confidence"` + Strength int `json:"strength"` +} + +func (g *Generator) Generate(ctx context.Context, input GenerateInput) ([]MemoryRecord, *llmcli.UsageInfo, error) { + if g.Runner == nil { + g.Runner = &llmcli.Runner{} + } + + if input.MaxRecords <= 0 { + input.MaxRecords = DefaultMaxRecords + } + + raw, usage, err := g.Runner.Execute(ctx, BuildPrompt(input)) + if err != nil { + return nil, nil, fmt.Errorf("execute memory-loop prompt: %w", err) + } + + var resp generateResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return nil, nil, fmt.Errorf("parse memory-loop JSON: %w", err) + } + + return buildGeneratedRecords(resp, input, time.Now().UTC()), usage, nil +} + +func buildGeneratedRecords(resp generateResponse, input GenerateInput, now time.Time) []MemoryRecord { + seen := make(map[string]struct{}) + records := make([]MemoryRecord, 0, len(resp.Records)) + for _, item := range resp.Records { + title := strings.TrimSpace(item.Title) + body := strings.TrimSpace(item.Body) + if title == "" || body == "" { + continue + } + kind := item.Kind + if kind == "" { + kind = KindRepoRule + } + key := strings.ToLower(string(kind) + "|" + title + "|" + body) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + record := MemoryRecord{ + ID: makeRecordID(kind, title), + Kind: kind, + Title: title, + Body: body, + Why: strings.TrimSpace(item.Why), + Evidence: trimSlice(item.Evidence, 3), + SourceSessionIDs: trimSlice(item.SourceSessionIDs, input.SourceWindow), + Confidence: normalizeConfidence(item.Confidence), + Strength: clamp(item.Strength, 1, 5), + Status: StatusCandidate, + Origin: OriginGenerated, + Fingerprint: fingerprintForRecord(kind, title, body), + CreatedAt: now, + UpdatedAt: now, + } + records = append(records, record) + if len(records) >= input.MaxRecords { + break + } + } + + return records +} + +func BuildPrompt(input GenerateInput) string { + var sb strings.Builder + + fmt.Fprintf(&sb, "Build a compact repo memory snapshot from the last %d AI coding sessions.\n\n", input.SourceWindow) + + sb.WriteString("\n") + fmt.Fprintf(&sb, "sessions: %d\n", input.Analysis.SessionCount) + for _, signal := range input.Analysis.RepeatedInstructions { + fmt.Fprintf(&sb, "repeated_instruction: %s (%d)\n", signal.Value, signal.Count) + for _, evidence := range trimSlice(signal.Evidence, 3) { + fmt.Fprintf(&sb, " evidence: %q\n", evidence) + } + } + for _, signal := range input.Analysis.MissingContextSignals { + fmt.Fprintf(&sb, "missing_context: %s (%d)\n", signal.Value, signal.Count) + for _, evidence := range trimSlice(signal.Evidence, 3) { + fmt.Fprintf(&sb, " evidence: %q\n", evidence) + } + } + for _, signal := range input.Analysis.FailureLoops { + fmt.Fprintf(&sb, "failure_loop: %s (%d)\n", signal.Value, signal.Count) + for _, evidence := range trimSlice(signal.Evidence, 3) { + fmt.Fprintf(&sb, " evidence: %q\n", evidence) + } + } + for _, opportunity := range input.Analysis.SkillOpportunities { + fmt.Fprintf(&sb, "skill_opportunity: %s (%d)\n", opportunity.SkillName, opportunity.Count) + if opportunity.SkillPath != "" { + fmt.Fprintf(&sb, " path: %s\n", opportunity.SkillPath) + } + if opportunity.MissingInstruction != "" { + fmt.Fprintf(&sb, " missing_instruction: %s\n", opportunity.MissingInstruction) + } + for _, friction := range trimSlice(opportunity.Friction, 3) { + fmt.Fprintf(&sb, " friction: %q\n", friction) + } + } + for _, learning := range trimSlice(input.Analysis.RepoLearnings, 5) { + fmt.Fprintf(&sb, "repo_learning: %s\n", learning) + } + for _, learning := range trimSlice(input.Analysis.WorkflowLearnings, 5) { + fmt.Fprintf(&sb, "workflow_learning: %s\n", learning) + } + sb.WriteString("\n\n") + + sb.WriteString("\n") + for _, session := range input.Sessions { + fmt.Fprintf(&sb, "session: %s agent=%s model=%s\n", session.SessionID, session.Agent, session.Model) + for _, friction := range trimSlice(session.Friction, 3) { + fmt.Fprintf(&sb, " friction: %q\n", friction) + } + for _, instruction := range session.Facets.RepeatedUserInstructions { + fmt.Fprintf(&sb, " repeated_instruction: %q\n", instruction.Instruction) + } + for _, signal := range session.Facets.MissingContext { + fmt.Fprintf(&sb, " missing_context: %q\n", signal.Item) + } + for _, signal := range session.Facets.SkillSignals { + fmt.Fprintf(&sb, " skill_signal: %s | %s\n", signal.SkillName, signal.MissingInstruction) + } + } + sb.WriteString("\n\n") + + fmt.Fprintf(&sb, `Return ONLY JSON with this structure: +{ + "records": [{ + "kind": "repo_rule|workflow_rule|agent_instruction|skill_patch|anti_pattern", + "title": "short title", + "body": "one actionable sentence", + "why": "why this memory matters for later sessions", + "evidence": ["short quote"], + "source_session_ids": ["session-id"], + "confidence": "high|medium|low", + "strength": 1 + }] +} + +Guidelines: +- Distill stable repo-specific lessons, not generic coding advice +- Prefer rules the developer would want injected into future Claude sessions +- Convert skill-related friction into skill_patch memories when appropriate +- Avoid duplicates and avoid one-off incidents without repeated evidence +- Return at most %d records +`, input.MaxRecords) + + return sb.String() +} + +func makeRecordID(kind Kind, title string) string { + base := strings.ToLower(strings.TrimSpace(title)) + var b strings.Builder + lastDash := false + for _, r := range base { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + default: + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" { + slug = defaultMemorySlug + } + return fmt.Sprintf("%s-%s", kind, slug) +} + +func trimSlice[T any](items []T, limit int) []T { + if limit <= 0 || len(items) <= limit { + return items + } + return items[:limit] +} + +func normalizeConfidence(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "high", "medium", "low": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "medium" + } +} + +func clamp(value, lo, hi int) int { + if value < lo { + return lo + } + if value > hi { + return hi + } + return value +} diff --git a/cmd/entire/cli/memoryloop/memoryloop.go b/cmd/entire/cli/memoryloop/memoryloop.go new file mode 100644 index 000000000..f63cec397 --- /dev/null +++ b/cmd/entire/cli/memoryloop/memoryloop.go @@ -0,0 +1,1037 @@ +package memoryloop + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode" + + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +const ( + fileName = "memory-loop.json" + DefaultMaxInjected = 3 + DefaultRefreshWindow = 20 + maxInjectionLogs = 50 + maxInjectionBytes = 1800 +) + +var stopWords = map[string]struct{}{ + "and": {}, + "before": {}, + "from": {}, + "into": {}, + "that": {}, + "the": {}, + "this": {}, + "use": {}, + "with": {}, +} + +type Kind string + +const ( + KindRepoRule Kind = "repo_rule" + KindWorkflowRule Kind = "workflow_rule" + KindAgentInstruction Kind = "agent_instruction" + KindSkillPatch Kind = "skill_patch" + KindAntiPattern Kind = "anti_pattern" +) + +type Mode string + +const ( + ModeOff Mode = "off" + ModeManual Mode = "manual" + ModeAuto Mode = "auto" +) + +type ActivationPolicy string + +const ( + ActivationPolicyReview ActivationPolicy = "review" + ActivationPolicyAuto ActivationPolicy = "auto" +) + +type Status string + +const ( + StatusCandidate Status = "candidate" + StatusActive Status = "active" + StatusSuppressed Status = "suppressed" + StatusArchived Status = "archived" +) + +type ScopeKind string + +const ( + ScopeKindMe ScopeKind = "me" + ScopeKindRepo ScopeKind = "repo" +) + +type Origin string + +const ( + OriginGenerated Origin = "generated" + OriginManual Origin = "manual" +) + +type Outcome string + +const ( + OutcomeNeutral Outcome = "neutral" + OutcomeReinforced Outcome = "reinforced" + OutcomeIneffective Outcome = "ineffective" +) + +type LifecycleAction string + +const ( + LifecycleActionActivate LifecycleAction = "activate" + LifecycleActionPromote LifecycleAction = "promote" + LifecycleActionSuppress LifecycleAction = "suppress" + LifecycleActionUnsuppress LifecycleAction = "unsuppress" + LifecycleActionArchive LifecycleAction = "archive" +) + +type HistoryEvent struct { + Type string `json:"type"` + At time.Time `json:"at"` + Detail string `json:"detail,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +type MemoryRecord struct { + ID string `json:"id"` + Kind Kind `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Why string `json:"why,omitempty"` + Evidence []string `json:"evidence,omitempty"` + SourceSessionIDs []string `json:"source_session_ids,omitempty"` + Confidence string `json:"confidence,omitempty"` + Strength int `json:"strength"` + Status Status `json:"status"` + Fingerprint string `json:"fingerprint,omitempty"` + ScopeKind ScopeKind `json:"scope_kind,omitempty"` + ScopeValue string `json:"scope_value,omitempty"` + Origin Origin `json:"origin,omitempty"` + OwnerEmail string `json:"owner_email,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastReviewedAt time.Time `json:"last_reviewed_at,omitempty"` + LastInjectedAt time.Time `json:"last_injected_at,omitempty"` + LastMatchedAt time.Time `json:"last_matched_at,omitempty"` + InjectCount int `json:"inject_count,omitempty"` + MatchCount int `json:"match_count,omitempty"` + Outcome Outcome `json:"outcome,omitempty"` + History []HistoryEvent `json:"history,omitempty"` + LegacyInferred bool `json:"legacy_inferred,omitempty"` +} + +type RefreshHistory struct { + At time.Time `json:"at"` + Scope string `json:"scope,omitempty"` + ScopeValue string `json:"scope_value,omitempty"` + SourceWindow int `json:"source_window,omitempty"` + GeneratedCount int `json:"generated_count,omitempty"` + ActivatedCount int `json:"activated_count,omitempty"` + CandidateCount int `json:"candidate_count,omitempty"` +} + +type Store struct { + Version int `json:"version"` + GeneratedAt time.Time `json:"generated_at"` + SourceWindow int `json:"source_window"` + Scope string `json:"scope,omitempty"` + ScopeValue string `json:"scope_value,omitempty"` + Records []MemoryRecord `json:"records,omitempty"` + Mode Mode `json:"mode,omitempty"` + ActivationPolicy ActivationPolicy `json:"activation_policy,omitempty"` + InjectionEnabled bool `json:"injection_enabled"` + MaxInjected int `json:"max_injected"` + RefreshHistory []RefreshHistory `json:"refresh_history,omitempty"` +} + +type Snapshot = Store + +type InjectionLog struct { + SessionID string `json:"session_id"` + PromptPreview string `json:"prompt_preview"` + InjectedMemoryIDs []string `json:"injected_memory_ids,omitempty"` + InjectedAt time.Time `json:"injected_at"` + Reason string `json:"reason,omitempty"` +} + +type State struct { + Store *Store `json:"-"` + Snapshot *Snapshot `json:"-"` + InjectionLogs []InjectionLog `json:"injection_logs,omitempty"` +} + +type Match struct { + Record MemoryRecord + Score int + Reason string +} + +type ReconcileResult struct { + Records []MemoryRecord + History RefreshHistory +} + +type ManualRecordInput struct { + Kind Kind + Title string + Body string + ScopeKind ScopeKind + ScopeValue string + OwnerEmail string +} + +type PruneResult struct { + ArchivedCount int +} + +func StatePath(ctx context.Context) (string, error) { + path, err := paths.AbsPath(ctx, filepath.Join(paths.EntireDir, fileName)) + if err != nil { + return "", fmt.Errorf("resolve memory-loop path: %w", err) + } + return path, nil +} + +func LoadState(ctx context.Context) (*State, error) { + path, err := StatePath(ctx) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) //nolint:gosec // repo-local metadata path + if err != nil { + if os.IsNotExist(err) { + return &State{}, nil + } + return nil, fmt.Errorf("read memory-loop state: %w", err) + } + + var disk diskState + if err := json.Unmarshal(data, &disk); err != nil { + return nil, fmt.Errorf("parse memory-loop state: %w", err) + } + + state := &State{ + Store: disk.Store, + Snapshot: disk.Snapshot, + InjectionLogs: disk.InjectionLogs, + } + normalizeStateWithSource(state, disk.Store == nil && disk.Snapshot != nil) + return state, nil +} + +func SaveState(ctx context.Context, state *State) error { + if state == nil { + state = &State{} + } + normalizeState(state) + + path, err := StatePath(ctx) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create memory-loop directory: %w", err) + } + + data, err := jsonutil.MarshalIndentWithNewline(diskState{ + Store: state.Store, + InjectionLogs: state.InjectionLogs, + }, "", " ") + if err != nil { + return fmt.Errorf("marshal memory-loop state: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { //nolint:gosec // repo-local metadata path + return fmt.Errorf("write memory-loop state: %w", err) + } + return nil +} + +func AppendInjectionLog(ctx context.Context, log InjectionLog) error { + state, err := LoadState(ctx) + if err != nil { + return err + } + state.InjectionLogs = append(state.InjectionLogs, log) + if len(state.InjectionLogs) > maxInjectionLogs { + state.InjectionLogs = state.InjectionLogs[len(state.InjectionLogs)-maxInjectionLogs:] + } + return SaveState(ctx, state) +} + +func SelectRelevant(snapshot Snapshot, prompt string, now time.Time) []Match { + maxInjected := snapshot.MaxInjected + if maxInjected <= 0 { + maxInjected = DefaultMaxInjected + } + + promptTokens := tokenize(prompt) + if len(promptTokens) == 0 { + return nil + } + + matches := make([]Match, 0, len(snapshot.Records)) + for _, record := range snapshot.Records { + if record.Status != "" && record.Status != StatusActive { + continue + } + score, reason := scoreRecord(record, promptTokens, now) + if score <= 0 { + continue + } + matches = append(matches, Match{ + Record: record, + Score: score, + Reason: reason, + }) + } + + sort.SliceStable(matches, func(i, j int) bool { + if matches[i].Score != matches[j].Score { + return matches[i].Score > matches[j].Score + } + if matches[i].Record.Strength != matches[j].Record.Strength { + return matches[i].Record.Strength > matches[j].Record.Strength + } + return matches[i].Record.Title < matches[j].Record.Title + }) + + if len(matches) > maxInjected { + matches = matches[:maxInjected] + } + return matches +} + +func ReconcileGeneratedRecords(existing, generated []MemoryRecord, scopeKind ScopeKind, scopeValue string, policy ActivationPolicy, now time.Time) ReconcileResult { + records := append([]MemoryRecord(nil), existing...) + byScopeKey := make(map[string]int, len(records)) + for i := range records { + fingerprint := records[i].Fingerprint + if fingerprint == "" { + fingerprint = fingerprintForRecord(records[i].Kind, records[i].Title, records[i].Body) + records[i].Fingerprint = fingerprint + } + if records[i].ScopeKind == "" { + records[i].ScopeKind = scopeKind + } + byScopeKey[recordScopeKey(records[i].Fingerprint, records[i].ScopeKind, records[i].ScopeValue)] = i + } + + result := ReconcileResult{ + Records: records, + History: RefreshHistory{ + At: now, + Scope: string(scopeKind), + ScopeValue: scopeValue, + SourceWindow: DefaultRefreshWindow, + }, + } + + for _, generatedRecord := range generated { + result.History.GeneratedCount++ + + record := generatedRecord + if record.Kind == "" { + record.Kind = KindRepoRule + } + if record.Origin == "" { + record.Origin = OriginGenerated + } + if record.Fingerprint == "" { + record.Fingerprint = fingerprintForRecord(record.Kind, record.Title, record.Body) + } + if record.ScopeKind == "" { + record.ScopeKind = scopeKind + } + if record.ScopeValue == "" { + record.ScopeValue = scopeValue + } + if record.CreatedAt.IsZero() { + record.CreatedAt = now + } + record.UpdatedAt = now + + if idx, exists := findReconcileIndex(result.Records, byScopeKey, record); exists { + reconciled := result.Records[idx] + reconciled.Kind = record.Kind + reconciled.Title = record.Title + reconciled.Body = record.Body + reconciled.Why = record.Why + reconciled.Evidence = record.Evidence + reconciled.SourceSessionIDs = record.SourceSessionIDs + reconciled.Confidence = record.Confidence + reconciled.Strength = record.Strength + reconciled.Fingerprint = record.Fingerprint + reconciled.ScopeKind = record.ScopeKind + reconciled.ScopeValue = record.ScopeValue + reconciled.Origin = record.Origin + reconciled.UpdatedAt = now + reconciled.Status = reconciledStatus(reconciled.Status, record.ScopeKind, policy) + switch reconciled.Status { + case StatusActive: + result.History.ActivatedCount++ + case StatusCandidate: + result.History.CandidateCount++ + case StatusSuppressed, StatusArchived: + } + result.Records[idx] = reconciled + continue + } + + record.Status = reconciledStatus(record.Status, record.ScopeKind, policy) + switch record.Status { + case StatusActive: + result.History.ActivatedCount++ + case StatusCandidate: + result.History.CandidateCount++ + case StatusSuppressed, StatusArchived: + } + + result.Records = append(result.Records, record) + byScopeKey[recordScopeKey(record.Fingerprint, record.ScopeKind, record.ScopeValue)] = len(result.Records) - 1 + } + + return result +} + +func reconciledStatus(existing Status, scopeKind ScopeKind, policy ActivationPolicy) Status { + switch existing { + case StatusSuppressed, StatusArchived: + return existing + case StatusActive: + if scopeKind == ScopeKindMe { + return StatusActive + } + case StatusCandidate: + } + + if scopeKind == ScopeKindRepo { + return StatusCandidate + } + if policy == ActivationPolicyAuto { + return StatusActive + } + return StatusCandidate +} + +func recordScopeKey(fingerprint string, scopeKind ScopeKind, scopeValue string) string { + return strings.Join([]string{fingerprint, string(scopeKind), scopeValue}, "|") +} + +func findReconcileIndex(records []MemoryRecord, byScopeKey map[string]int, record MemoryRecord) (int, bool) { + if idx, exists := byScopeKey[recordScopeKey(record.Fingerprint, record.ScopeKind, record.ScopeValue)]; exists { + return idx, true + } + if record.ScopeKind == ScopeKindMe && record.ScopeValue != "" { + if idx, exists := byScopeKey[recordScopeKey(record.Fingerprint, record.ScopeKind, "")]; exists { + return idx, true + } + } + + bestIdx := -1 + bestScore := 0.0 + for i, existing := range records { + if existing.Kind != record.Kind { + continue + } + if !sameReconcileScope(existing, record) { + continue + } + score := logicalRuleSimilarity(existing, record) + if score > bestScore { + bestScore = score + bestIdx = i + } + } + if bestIdx >= 0 && bestScore >= 0.50 { + return bestIdx, true + } + return -1, false +} + +func logicalRuleSimilarity(a, b MemoryRecord) float64 { + aTokens := tokenize(strings.Join([]string{a.Title, a.Body}, " ")) + bTokens := tokenize(strings.Join([]string{b.Title, b.Body}, " ")) + if len(aTokens) == 0 || len(bTokens) == 0 { + return 0 + } + + intersection := 0 + for token := range aTokens { + if _, ok := bTokens[token]; ok { + intersection++ + } + } + if intersection < 3 { + return 0 + } + + union := len(aTokens) + for token := range bTokens { + if _, ok := aTokens[token]; !ok { + union++ + } + } + if union == 0 { + return 0 + } + + jaccard := float64(intersection) / float64(union) + minSize := len(aTokens) + if len(bTokens) < minSize { + minSize = len(bTokens) + } + if minSize == 0 { + return 0 + } + overlapCoeff := float64(intersection) / float64(minSize) + if overlapCoeff > jaccard { + return overlapCoeff + } + return jaccard +} + +func sameReconcileScope(a, b MemoryRecord) bool { + if a.ScopeKind != b.ScopeKind { + return false + } + if a.ScopeValue == b.ScopeValue { + return true + } + if a.ScopeKind == ScopeKindMe && (a.ScopeValue == "" || b.ScopeValue == "") { + return true + } + return false +} + +func FormatInjectionBlock(matches []Match) string { + if len(matches) == 0 { + return "" + } + + var buf bytes.Buffer + buf.WriteString("Memory For This Repo\n") + for _, match := range matches { + buf.WriteString("- ") + buf.WriteString(strings.TrimSpace(match.Record.Title)) + if body := strings.TrimSpace(match.Record.Body); body != "" { + buf.WriteString(": ") + buf.WriteString(body) + } + if why := strings.TrimSpace(match.Record.Why); why != "" { + buf.WriteString(" Why: ") + buf.WriteString(why) + } + buf.WriteByte('\n') + if buf.Len() >= maxInjectionBytes { + break + } + } + + out := strings.TrimSpace(buf.String()) + if len(out) > maxInjectionBytes { + out = out[:maxInjectionBytes] + } + return out +} + +func TransitionRecordLifecycle(records []MemoryRecord, id string, action LifecycleAction, now time.Time) ([]MemoryRecord, MemoryRecord, error) { + updated := append([]MemoryRecord(nil), records...) + for i := range updated { + if updated[i].ID != id { + continue + } + + record, err := transitionRecord(updated[i], action, now) + if err != nil { + return updated, updated[i], err + } + updated[i] = record + return updated, record, nil + } + + return updated, MemoryRecord{}, fmt.Errorf("memory not found: %s", id) +} + +func AddManualRecord(records []MemoryRecord, input ManualRecordInput, now time.Time) ([]MemoryRecord, MemoryRecord, error) { + if strings.TrimSpace(input.Title) == "" { + return records, MemoryRecord{}, errors.New("memory title is required") + } + if strings.TrimSpace(input.Body) == "" { + return records, MemoryRecord{}, errors.New("memory body is required") + } + if input.Kind == "" { + return records, MemoryRecord{}, errors.New("memory kind is required") + } + if input.ScopeKind == "" { + input.ScopeKind = ScopeKindMe + } + + record := MemoryRecord{ + ID: makeRecordID(input.Kind, input.Title), + Kind: input.Kind, + Title: strings.TrimSpace(input.Title), + Body: strings.TrimSpace(input.Body), + Fingerprint: fingerprintForRecord(input.Kind, input.Title, input.Body), + ScopeKind: input.ScopeKind, + ScopeValue: strings.TrimSpace(input.ScopeValue), + Origin: OriginManual, + OwnerEmail: strings.TrimSpace(input.OwnerEmail), + Status: StatusActive, + CreatedAt: now, + UpdatedAt: now, + History: []HistoryEvent{ + {Type: "added", At: now}, + }, + } + + updated := append([]MemoryRecord(nil), records...) + if idx, exists := findReconcileIndex(updated, indexRecordScopeKeys(updated), record); exists { + existing := updated[idx] + existing.Kind = record.Kind + existing.Title = record.Title + existing.Body = record.Body + existing.Fingerprint = record.Fingerprint + existing.ScopeKind = record.ScopeKind + existing.ScopeValue = record.ScopeValue + existing.Origin = OriginManual + existing.OwnerEmail = record.OwnerEmail + existing.Status = StatusActive + existing.UpdatedAt = now + existing.LastReviewedAt = now + existing.History = append(existing.History, HistoryEvent{Type: "added", At: now}) + updated[idx] = existing + return updated, existing, nil + } + + updated = append(updated, record) + return updated, record, nil +} + +func RecordInjectionActivity(state *State, matches []Match, log InjectionLog, now time.Time) { + if state == nil || state.Store == nil { + return + } + + matchIDs := make(map[string]struct{}, len(matches)) + for _, match := range matches { + matchIDs[match.Record.ID] = struct{}{} + } + + for i := range state.Store.Records { + if _, ok := matchIDs[state.Store.Records[i].ID]; !ok { + continue + } + state.Store.Records[i].MatchCount++ + state.Store.Records[i].LastMatchedAt = now + state.Store.Records[i].History = append(state.Store.Records[i].History, HistoryEvent{ + Type: "matched", + At: now, + }) + state.Store.Records[i].InjectCount++ + state.Store.Records[i].LastInjectedAt = now + state.Store.Records[i].History = append(state.Store.Records[i].History, HistoryEvent{ + Type: "injected", + At: now, + }) + } + + state.InjectionLogs = append(state.InjectionLogs, log) + if len(state.InjectionLogs) > maxInjectionLogs { + state.InjectionLogs = state.InjectionLogs[len(state.InjectionLogs)-maxInjectionLogs:] + } +} + +func DeriveOutcomes(records []MemoryRecord, sessions []insightsdb.SessionRow, _ time.Time) []MemoryRecord { + return DeriveOutcomesFromEvidence(records, []string{buildOutcomeSignalText(sessions)}, time.Time{}) +} + +func DeriveOutcomesFromEvidence(records []MemoryRecord, evidence []string, _ time.Time) []MemoryRecord { + updated := append([]MemoryRecord(nil), records...) + signalTokens := tokenize(strings.Join(evidence, " ")) + + for i := range updated { + if updated[i].Origin == OriginManual { + updated[i].Outcome = OutcomeNeutral + continue + } + + recordTokens := tokenize(strings.Join([]string{updated[i].Title, updated[i].Body, updated[i].Why}, " ")) + overlap := 0 + for token := range recordTokens { + if _, ok := signalTokens[token]; ok { + overlap++ + } + } + + switch { + case overlap == 0: + updated[i].Outcome = OutcomeNeutral + case updated[i].InjectCount > 0: + updated[i].Outcome = OutcomeIneffective + default: + updated[i].Outcome = OutcomeReinforced + } + } + + return updated +} + +func PruneRecords(records []MemoryRecord, now time.Time) ([]MemoryRecord, PruneResult) { + updated := append([]MemoryRecord(nil), records...) + result := PruneResult{} + + for i := range updated { + if updated[i].Origin == OriginManual { + continue + } + + reason := pruneReason(updated[i], now) + if reason == "" { + continue + } + + updated[i].Status = StatusArchived + updated[i].UpdatedAt = now + updated[i].History = append(updated[i].History, HistoryEvent{ + Type: "pruned", + At: now, + Detail: reason, + }) + result.ArchivedCount++ + } + + return updated, result +} + +func transitionRecord(record MemoryRecord, action LifecycleAction, now time.Time) (MemoryRecord, error) { + nextStatus, err := nextLifecycleStatus(record, action) + if err != nil { + return record, err + } + + record.Status = nextStatus + record.UpdatedAt = now + record.LastReviewedAt = now + record.History = append(record.History, HistoryEvent{ + Type: lifecycleHistoryType(action), + At: now, + }) + return record, nil +} + +func nextLifecycleStatus(record MemoryRecord, action LifecycleAction) (Status, error) { + switch action { + case LifecycleActionActivate: + if record.ScopeKind == ScopeKindRepo { + return record.Status, fmt.Errorf("repo-scoped memory %q requires promote, not activate", record.ID) + } + if record.Status == StatusSuppressed { + return record.Status, fmt.Errorf("memory %q is suppressed; use unsuppress first", record.ID) + } + if record.Status == StatusArchived { + return record.Status, fmt.Errorf("memory %q is archived", record.ID) + } + return StatusActive, nil + case LifecycleActionPromote: + if record.ScopeKind != ScopeKindRepo { + return record.Status, fmt.Errorf("memory %q is not repo-scoped", record.ID) + } + if record.Status == StatusSuppressed { + return record.Status, fmt.Errorf("memory %q is suppressed; use unsuppress first", record.ID) + } + if record.Status == StatusArchived { + return record.Status, fmt.Errorf("memory %q is archived", record.ID) + } + return StatusActive, nil + case LifecycleActionSuppress: + if record.Status == StatusArchived { + return record.Status, fmt.Errorf("memory %q is archived", record.ID) + } + return StatusSuppressed, nil + case LifecycleActionUnsuppress: + if record.Status != StatusSuppressed { + return record.Status, fmt.Errorf("memory %q is not suppressed", record.ID) + } + return StatusCandidate, nil + case LifecycleActionArchive: + return StatusArchived, nil + default: + return record.Status, fmt.Errorf("unsupported lifecycle action: %s", action) + } +} + +func lifecycleHistoryType(action LifecycleAction) string { + switch action { + case LifecycleActionActivate: + return "activated" + case LifecycleActionPromote: + return "promoted" + case LifecycleActionSuppress: + return "suppressed" + case LifecycleActionUnsuppress: + return "unsuppressed" + case LifecycleActionArchive: + return "archived" + default: + return string(action) + } +} + +func indexRecordScopeKeys(records []MemoryRecord) map[string]int { + byScopeKey := make(map[string]int, len(records)) + for i := range records { + fingerprint := records[i].Fingerprint + if fingerprint == "" { + fingerprint = fingerprintForRecord(records[i].Kind, records[i].Title, records[i].Body) + records[i].Fingerprint = fingerprint + } + byScopeKey[recordScopeKey(fingerprint, records[i].ScopeKind, records[i].ScopeValue)] = i + } + return byScopeKey +} + +func buildOutcomeSignalText(sessions []insightsdb.SessionRow) string { + parts := make([]string, 0, len(sessions)*4) + for _, session := range sessions { + parts = append(parts, session.Friction...) + for _, learning := range session.Learnings { + parts = append(parts, learning.Finding) + } + parts = append(parts, session.Facets.RepoGotchas...) + parts = append(parts, session.Facets.WorkflowGaps...) + for _, item := range session.Facets.MissingContext { + parts = append(parts, item.Item) + } + for _, item := range session.Facets.RepeatedUserInstructions { + parts = append(parts, item.Instruction) + } + for _, item := range session.Facets.FailureLoops { + parts = append(parts, item.Description) + } + for _, item := range session.Facets.SkillSignals { + parts = append(parts, item.Friction...) + if item.MissingInstruction != "" { + parts = append(parts, item.MissingInstruction) + } + } + } + return strings.Join(parts, " ") +} + +func pruneReason(record MemoryRecord, now time.Time) string { + if record.Status == StatusArchived { + return "" + } + + lastActivity := record.UpdatedAt + for _, candidate := range []time.Time{ + record.CreatedAt, + record.LastMatchedAt, + record.LastInjectedAt, + record.LastReviewedAt, + } { + if candidate.After(lastActivity) { + lastActivity = candidate + } + } + + switch { + case record.Status == StatusCandidate && !lastActivity.IsZero() && now.Sub(lastActivity) >= 30*24*time.Hour: + return "stale_candidate" + case record.Status == StatusActive && record.MatchCount == 0 && !lastActivity.IsZero() && now.Sub(lastActivity) >= 60*24*time.Hour: + return "stale_unmatched_active" + case record.Status == StatusActive && record.Outcome == OutcomeIneffective && record.InjectCount >= 3: + return "ineffective_active" + default: + return "" + } +} + +func normalizeState(state *State) { + normalizeStateWithSource(state, false) +} + +func normalizeStateWithSource(state *State, loadedFromLegacySnapshot bool) { + if state == nil { + return + } + loadedFromLegacySnapshot = loadedFromLegacySnapshot || (state.Store == nil && state.Snapshot != nil) + switch { + case state.Store == nil && state.Snapshot != nil: + state.Store = state.Snapshot + case state.Store != nil && state.Snapshot == nil: + state.Snapshot = state.Store + } + if state.Store == nil { + return + } + + if state.Store.Version == 0 { + state.Store.Version = 1 + } + if state.Store.MaxInjected <= 0 { + state.Store.MaxInjected = DefaultMaxInjected + } + if state.Store.SourceWindow <= 0 { + state.Store.SourceWindow = DefaultRefreshWindow + } + if state.Store.Mode == "" { + if state.Store.InjectionEnabled { + state.Store.Mode = ModeAuto + } else { + state.Store.Mode = ModeManual + } + } + state.Store.InjectionEnabled = state.Store.Mode == ModeAuto + if state.Store.ActivationPolicy == "" { + state.Store.ActivationPolicy = ActivationPolicyReview + } + for i := range state.Store.Records { + record := &state.Store.Records[i] + inferred := false + if record.Status == "" { + record.Status = StatusActive + inferred = true + } + if record.Origin == "" { + record.Origin = OriginGenerated + inferred = true + } + if record.ScopeKind == "" { + record.ScopeKind = ScopeKindMe + inferred = true + } + if record.Outcome == "" { + record.Outcome = OutcomeNeutral + inferred = true + } + if record.Fingerprint == "" { + record.Fingerprint = fingerprintForRecord(record.Kind, record.Title, record.Body) + } + if loadedFromLegacySnapshot && inferred { + record.LegacyInferred = true + } + } + state.Snapshot = state.Store +} + +type diskState struct { + Store *Store `json:"store,omitempty"` + Snapshot *Snapshot `json:"snapshot,omitempty"` + InjectionLogs []InjectionLog `json:"injection_logs,omitempty"` +} + +func scoreRecord(record MemoryRecord, promptTokens map[string]struct{}, now time.Time) (int, string) { + recordTokens := tokenize(strings.Join([]string{ + record.Title, + record.Body, + record.Why, + strings.Join(record.Evidence, " "), + }, " ")) + if len(recordTokens) == 0 { + return 0, "" + } + + overlap := 0 + for token := range promptTokens { + if _, ok := recordTokens[token]; ok { + overlap++ + } + } + if overlap == 0 { + return 0, "" + } + + score := overlap * 10 + switch record.Kind { + case KindRepoRule, KindAgentInstruction: + score += 15 + case KindSkillPatch: + score += 4 + case KindWorkflowRule, KindAntiPattern, "": + } + score += minInt(record.Strength, 5) + + if !record.UpdatedAt.IsZero() && now.After(record.UpdatedAt) { + age := now.Sub(record.UpdatedAt) + if age <= 14*24*time.Hour { + score += 2 + } + } + + return score, "keyword overlap" +} + +func tokenize(text string) map[string]struct{} { + fields := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) + }) + tokens := make(map[string]struct{}, len(fields)) + for _, field := range fields { + if len(field) < 3 { + continue + } + if _, ok := stopWords[field]; ok { + continue + } + tokens[field] = struct{}{} + } + return tokens +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func fingerprintForRecord(kind Kind, title, body string) string { + base := strings.ToLower(strings.TrimSpace(strings.Join([]string{ + string(kind), + strings.TrimSpace(title), + strings.TrimSpace(body), + }, "|"))) + if base == "" { + return "memory" + } + var b strings.Builder + lastDash := false + for _, r := range base { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + default: + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "memory" + } + return out +} diff --git a/cmd/entire/cli/memoryloop/memoryloop_test.go b/cmd/entire/cli/memoryloop/memoryloop_test.go new file mode 100644 index 000000000..a468b82fb --- /dev/null +++ b/cmd/entire/cli/memoryloop/memoryloop_test.go @@ -0,0 +1,1229 @@ +package memoryloop + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/facets" + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/stretchr/testify/require" +) + +func TestSaveAndLoadState(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + now := time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC) + state := &State{ + Snapshot: &Snapshot{ + Version: 1, + GeneratedAt: now, + SourceWindow: 20, + InjectionEnabled: true, + MaxInjected: 3, + Records: []MemoryRecord{ + { + ID: "repo-rule-run-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run lint before claiming the task is complete.", + Why: "This repo repeatedly fails on lint after otherwise-correct edits.", + Evidence: []string{"lint failed after code changes"}, + SourceSessionIDs: []string{"sess-1", "sess-2"}, + Confidence: "high", + Strength: 4, + Status: StatusActive, + CreatedAt: now, + UpdatedAt: now, + }, + }, + }, + InjectionLogs: []InjectionLog{ + { + SessionID: "sess-next", + PromptPreview: "fix the lint issue in capabilities.go", + InjectedMemoryIDs: []string{"repo-rule-run-lint"}, + InjectedAt: now, + Reason: "keyword overlap", + }, + }, + } + + require.NoError(t, SaveState(context.Background(), state)) + + loaded, err := LoadState(context.Background()) + require.NoError(t, err) + require.NotNil(t, loaded.Snapshot) + require.Len(t, loaded.Snapshot.Records, 1) + require.Equal(t, "Run lint before finishing", loaded.Snapshot.Records[0].Title) + require.Len(t, loaded.InjectionLogs, 1) + require.Equal(t, "sess-next", loaded.InjectionLogs[0].SessionID) +} + +func TestSelectRelevantPrefersMatchingRecords(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC) + snapshot := Snapshot{ + MaxInjected: 2, + Records: []MemoryRecord{ + { + ID: "lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Strength: 5, + Status: StatusActive, + UpdatedAt: now, + Confidence: "high", + }, + { + ID: "skills", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + Body: "If a project skill causes friction, update the SKILL.md with the missing step.", + Strength: 4, + Status: StatusActive, + UpdatedAt: now, + Confidence: "medium", + }, + { + ID: "irrelevant", + Kind: KindWorkflowRule, + Title: "Keep commit messages short", + Body: "Use concise commit subjects.", + Strength: 1, + Status: StatusActive, + UpdatedAt: now, + Confidence: "low", + }, + }, + } + + matches := SelectRelevant(snapshot, "fix the lint failure and update the skill instructions", now) + require.Len(t, matches, 2) + require.Equal(t, "lint", matches[0].Record.ID) + require.Equal(t, "skills", matches[1].Record.ID) +} + +func TestFormatInjectionBlock(t *testing.T) { + t.Parallel() + + block := FormatInjectionBlock([]Match{ + { + Record: MemoryRecord{ + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Why: "This repo frequently fails on lint after edits.", + }, + Reason: "keyword overlap", + }, + }) + + require.Contains(t, block, "Memory For This Repo") + require.Contains(t, block, "Run lint before finishing") + require.Contains(t, block, "Run golangci-lint before claiming completion.") +} + +func TestLoadState_BackfillsHeavyweightDefaultsFromLegacySnapshot(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + statePath := filepath.Join(tmpDir, paths.EntireDir, "memory-loop.json") + require.NoError(t, os.MkdirAll(filepath.Dir(statePath), 0o755)) + require.NoError(t, os.WriteFile(statePath, []byte(`{ + "snapshot": { + "version": 1, + "generated_at": "2026-03-25T12:00:00Z", + "source_window": 20, + "injection_enabled": true, + "max_injected": 3, + "records": [{ + "id": "repo-rule-run-lint", + "kind": "repo_rule", + "title": "Run lint before finishing", + "body": "Run lint before claiming the task is complete.", + "strength": 4 + }] + } +}`), 0o644)) + + loaded, err := LoadState(context.Background()) + require.NoError(t, err) + require.NotNil(t, loaded.Store) + require.Equal(t, ModeAuto, loaded.Store.Mode) + require.Equal(t, ActivationPolicyReview, loaded.Store.ActivationPolicy) + require.Len(t, loaded.Store.Records, 1) + require.Equal(t, StatusActive, loaded.Store.Records[0].Status) + require.Equal(t, OriginGenerated, loaded.Store.Records[0].Origin) + require.Equal(t, ScopeKindMe, loaded.Store.Records[0].ScopeKind) + require.True(t, loaded.Store.Records[0].LegacyInferred) +} + +func TestNormalizeState_DefaultsHeavyweightStoreFields(t *testing.T) { + t.Parallel() + + state := &State{ + Store: &Store{ + Records: []MemoryRecord{ + { + ID: "memory-1", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run lint before claiming completion.", + }, + }, + }, + } + + normalizeState(state) + + require.Equal(t, ModeManual, state.Store.Mode) + require.Equal(t, ActivationPolicyReview, state.Store.ActivationPolicy) + require.Equal(t, DefaultMaxInjected, state.Store.MaxInjected) + require.Len(t, state.Store.Records, 1) + require.Equal(t, StatusActive, state.Store.Records[0].Status) + require.Equal(t, OriginGenerated, state.Store.Records[0].Origin) + require.Equal(t, ScopeKindMe, state.Store.Records[0].ScopeKind) + require.NotEmpty(t, state.Store.Records[0].Fingerprint) +} + +func TestNormalizeState_ModeWinsOverLegacyInjectionFlag(t *testing.T) { + t.Parallel() + + state := &State{ + Store: &Store{ + Mode: ModeOff, + InjectionEnabled: true, + }, + } + + normalizeState(state) + + require.Equal(t, ModeOff, state.Store.Mode) + require.False(t, state.Store.InjectionEnabled) +} + +func TestSaveState_SnapshotOnlyInputPreservesLegacyInference(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + now := time.Date(2026, time.March, 25, 12, 0, 0, 0, time.UTC) + state := &State{ + Snapshot: &Snapshot{ + Version: 1, + GeneratedAt: now, + SourceWindow: 20, + InjectionEnabled: true, + MaxInjected: 3, + Records: []MemoryRecord{ + { + ID: "repo-rule-run-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run lint before claiming completion.", + Strength: 4, + }, + }, + }, + } + + require.NoError(t, SaveState(context.Background(), state)) + + loaded, err := LoadState(context.Background()) + require.NoError(t, err) + require.NotNil(t, loaded.Store) + require.Len(t, loaded.Store.Records, 1) + require.True(t, loaded.Store.Records[0].LegacyInferred) +} + +func TestSaveState_ModeRemainsAuthoritativeOverInjectionEnabled(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + state := &State{ + Store: &Store{ + Version: 1, + GeneratedAt: time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC), + SourceWindow: 20, + Mode: ModeOff, + InjectionEnabled: true, + ActivationPolicy: ActivationPolicyReview, + MaxInjected: 3, + }, + } + + require.NoError(t, SaveState(context.Background(), state)) + + loaded, err := LoadState(context.Background()) + require.NoError(t, err) + require.Equal(t, ModeOff, loaded.Store.Mode) + require.False(t, loaded.Store.InjectionEnabled) +} + +func TestBuildGeneratedRecords_ProducesCandidateRecords(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + records := buildGeneratedRecords(generateResponse{ + Records: []generateRecord{ + { + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Why: "Lint failures recur after edits.", + Confidence: "high", + Strength: 4, + }, + }, + }, GenerateInput{ + SourceWindow: 20, + MaxRecords: 5, + }, now) + + require.Len(t, records, 1) + require.Equal(t, StatusCandidate, records[0].Status) + require.Equal(t, OriginGenerated, records[0].Origin) + require.NotEmpty(t, records[0].Fingerprint) + require.Equal(t, now, records[0].CreatedAt) +} + +func TestBuildGeneratedRecords_EmptyKindNormalizesBeforeIDGeneration(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 30, 0, 0, time.UTC) + records := buildGeneratedRecords(generateResponse{ + Records: []generateRecord{ + { + Kind: "", + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + }, + }, + }, GenerateInput{ + SourceWindow: 20, + MaxRecords: 5, + }, now) + + require.Len(t, records, 1) + require.Equal(t, KindRepoRule, records[0].Kind) + require.Equal(t, "repo_rule-run-lint-before-finishing", records[0].ID) +} + +func TestReconcileGeneratedRecords_PreservesSuppressedAndCountsOutcomes(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + existing := []MemoryRecord{ + { + ID: "suppressed-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion."), + Status: StatusSuppressed, + Origin: OriginGenerated, + }, + { + ID: "archived-commit", + Kind: KindWorkflowRule, + Title: "Keep commit subjects concise", + Body: "Use short imperative commit subjects.", + Fingerprint: fingerprintForRecord(KindWorkflowRule, "Keep commit subjects concise", "Use short imperative commit subjects."), + Status: StatusArchived, + Origin: OriginGenerated, + }, + } + generated := []MemoryRecord{ + { + ID: "lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion."), + Status: StatusCandidate, + Origin: OriginGenerated, + }, + { + ID: "skills", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + Body: "Update the project skill with missing retry steps.", + Fingerprint: fingerprintForRecord(KindSkillPatch, "Tighten the project skill", "Update the project skill with missing retry steps."), + Status: StatusCandidate, + Origin: OriginGenerated, + }, + { + ID: "repo-rule", + Kind: KindRepoRule, + Title: "Keep generated repo memories pending", + Body: "Require explicit promotion before shared repo memories inject.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Keep generated repo memories pending", "Require explicit promotion before shared repo memories inject."), + ScopeKind: ScopeKindRepo, + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindMe, "", ActivationPolicyAuto, now) + require.Len(t, result.Records, 4) + require.Equal(t, StatusSuppressed, result.Records[0].Status) + require.Equal(t, StatusArchived, result.Records[1].Status) + require.Equal(t, StatusActive, result.Records[2].Status) + require.Equal(t, ScopeKindMe, result.Records[2].ScopeKind) + require.Equal(t, StatusCandidate, result.Records[3].Status) + require.Equal(t, ScopeKindRepo, result.Records[3].ScopeKind) + require.Equal(t, 3, result.History.GeneratedCount) + require.Equal(t, 1, result.History.ActivatedCount) + require.Equal(t, 1, result.History.CandidateCount) + + repoResult := ReconcileGeneratedRecords(nil, generated[2:], ScopeKindRepo, "main", ActivationPolicyAuto, now) + require.Len(t, repoResult.Records, 1) + require.Equal(t, StatusCandidate, repoResult.Records[0].Status) + require.Equal(t, ScopeKindRepo, repoResult.Records[0].ScopeKind) + require.Equal(t, "main", repoResult.Records[0].ScopeValue) + require.Equal(t, 1, repoResult.History.CandidateCount) +} + +func TestReconcileGeneratedRecords_ReconcilesIntoExistingRecord(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 13, 0, 0, 0, time.UTC) + fingerprint := fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion.") + existing := []MemoryRecord{ + { + ID: "suppressed-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Old body", + Why: "Old why", + Fingerprint: fingerprint, + ScopeKind: ScopeKindMe, + Status: StatusSuppressed, + Origin: OriginGenerated, + CreatedAt: now.Add(-24 * time.Hour), + UpdatedAt: now.Add(-24 * time.Hour), + }, + } + generated := []MemoryRecord{ + { + ID: "lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Why: "Lint failures recur after edits.", + Fingerprint: fingerprint, + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindMe, "", ActivationPolicyAuto, now) + require.Len(t, result.Records, 1) + require.Equal(t, "suppressed-lint", result.Records[0].ID) + require.Equal(t, StatusSuppressed, result.Records[0].Status) + require.Equal(t, "Run golangci-lint before claiming completion.", result.Records[0].Body) + require.Equal(t, "Lint failures recur after edits.", result.Records[0].Why) + require.Equal(t, now, result.Records[0].UpdatedAt) + require.Equal(t, 1, result.History.GeneratedCount) + require.Equal(t, 0, result.History.ActivatedCount) + require.Equal(t, 0, result.History.CandidateCount) +} + +func TestReconcileGeneratedRecords_CountsReconciledActiveRecordsInHistory(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 15, 0, 0, 0, time.UTC) + fingerprint := fingerprintForRecord(KindSkillPatch, "Tighten the project skill", "Update the project skill with missing retry steps.") + existing := []MemoryRecord{ + { + ID: "skills", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + Body: "Old body", + Fingerprint: fingerprint, + ScopeKind: ScopeKindMe, + Status: StatusCandidate, + Origin: OriginGenerated, + CreatedAt: now.Add(-24 * time.Hour), + UpdatedAt: now.Add(-24 * time.Hour), + }, + } + generated := []MemoryRecord{ + { + ID: "skills-new", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + Body: "Update the project skill with missing retry steps.", + Fingerprint: fingerprint, + ScopeKind: ScopeKindMe, + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindMe, "", ActivationPolicyAuto, now) + require.Len(t, result.Records, 1) + require.Equal(t, StatusActive, result.Records[0].Status) + require.Equal(t, 1, result.History.GeneratedCount) + require.Equal(t, 1, result.History.ActivatedCount) + require.Equal(t, 0, result.History.CandidateCount) +} + +func TestReconcileGeneratedRecords_AllowsSameFingerprintAcrossScopes(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 14, 0, 0, 0, time.UTC) + fingerprint := fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion.") + existing := []MemoryRecord{ + { + ID: "personal-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprint, + ScopeKind: ScopeKindMe, + Status: StatusActive, + Origin: OriginGenerated, + }, + } + generated := []MemoryRecord{ + { + ID: "repo-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprint, + ScopeKind: ScopeKindRepo, + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindRepo, "main", ActivationPolicyAuto, now) + require.Len(t, result.Records, 2) + require.Equal(t, ScopeKindMe, result.Records[0].ScopeKind) + require.Equal(t, ScopeKindRepo, result.Records[1].ScopeKind) + require.Equal(t, StatusCandidate, result.Records[1].Status) + require.Equal(t, "main", result.Records[1].ScopeValue) + require.Equal(t, 1, result.History.GeneratedCount) + require.Equal(t, 1, result.History.CandidateCount) +} + +func TestReconcileGeneratedRecords_ReviewPolicyKeepsExistingPersonalActive(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 16, 0, 0, 0, time.UTC) + fingerprint := fingerprintForRecord(KindWorkflowRule, "Keep commit subjects concise", "Use short imperative commit subjects.") + existing := []MemoryRecord{ + { + ID: "commit-subjects", + Kind: KindWorkflowRule, + Title: "Keep commit subjects concise", + Body: "Old body", + Fingerprint: fingerprint, + ScopeKind: ScopeKindMe, + Status: StatusActive, + Origin: OriginGenerated, + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-48 * time.Hour), + }, + } + generated := []MemoryRecord{ + { + ID: "commit-subjects-refresh", + Kind: KindWorkflowRule, + Title: "Keep commit subjects concise", + Body: "Use short imperative commit subjects.", + Fingerprint: fingerprint, + ScopeKind: ScopeKindMe, + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindMe, "", ActivationPolicyReview, now) + require.Len(t, result.Records, 1) + require.Equal(t, StatusActive, result.Records[0].Status) + require.Equal(t, "Use short imperative commit subjects.", result.Records[0].Body) + require.Equal(t, 1, result.History.GeneratedCount) + require.Equal(t, 1, result.History.ActivatedCount) + require.Equal(t, 0, result.History.CandidateCount) +} + +func TestReconcileGeneratedRecords_RewordedSuppressedRecordReconcilesInPlace(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 17, 0, 0, 0, time.UTC) + existing := []MemoryRecord{ + { + ID: "suppressed-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion."), + ScopeKind: ScopeKindMe, + Status: StatusSuppressed, + Origin: OriginGenerated, + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-48 * time.Hour), + }, + } + generated := []MemoryRecord{ + { + ID: "lint-refresh", + Kind: KindRepoRule, + Title: "Run lint before wrapping up", + Body: "Run golangci-lint before you say the task is done.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before wrapping up", "Run golangci-lint before you say the task is done."), + ScopeKind: ScopeKindMe, + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindMe, "", ActivationPolicyAuto, now) + require.Len(t, result.Records, 1) + require.Equal(t, "suppressed-lint", result.Records[0].ID) + require.Equal(t, StatusSuppressed, result.Records[0].Status) + require.Equal(t, "Run lint before wrapping up", result.Records[0].Title) + require.Equal(t, "Run golangci-lint before you say the task is done.", result.Records[0].Body) +} + +func TestReconcileGeneratedRecords_RewordedActivePersonalRecordReconcilesInPlace(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 18, 0, 0, 0, time.UTC) + existing := []MemoryRecord{ + { + ID: "active-skill", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + Body: "Update the project skill with missing retry steps.", + Fingerprint: fingerprintForRecord(KindSkillPatch, "Tighten the project skill", "Update the project skill with missing retry steps."), + ScopeKind: ScopeKindMe, + Status: StatusActive, + Origin: OriginGenerated, + CreatedAt: now.Add(-72 * time.Hour), + UpdatedAt: now.Add(-72 * time.Hour), + }, + } + generated := []MemoryRecord{ + { + ID: "skill-refresh", + Kind: KindSkillPatch, + Title: "Strengthen the project skill", + Body: "Add the missing retry step to the project skill instructions.", + Fingerprint: fingerprintForRecord(KindSkillPatch, "Strengthen the project skill", "Add the missing retry step to the project skill instructions."), + ScopeKind: ScopeKindMe, + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindMe, "", ActivationPolicyReview, now) + require.Len(t, result.Records, 1) + require.Equal(t, "active-skill", result.Records[0].ID) + require.Equal(t, StatusActive, result.Records[0].Status) + require.Equal(t, "Strengthen the project skill", result.Records[0].Title) + require.Equal(t, "Add the missing retry step to the project skill instructions.", result.Records[0].Body) +} + +func TestReconcileGeneratedRecords_DuplicateGeneratedRulesInOneRefreshDoNotForkOrPanic(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 19, 0, 0, 0, time.UTC) + generated := []MemoryRecord{ + { + ID: "lint-1", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion."), + ScopeKind: ScopeKindMe, + ScopeValue: "me@example.com", + Status: StatusCandidate, + Origin: OriginGenerated, + }, + { + ID: "lint-2", + Kind: KindRepoRule, + Title: "Run lint before wrapping up", + Body: "Run golangci-lint before you say the task is done.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before wrapping up", "Run golangci-lint before you say the task is done."), + ScopeKind: ScopeKindMe, + ScopeValue: "me@example.com", + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(nil, generated, ScopeKindMe, "me@example.com", ActivationPolicyAuto, now) + require.Len(t, result.Records, 1) + require.Equal(t, "lint-1", result.Records[0].ID) + require.Equal(t, "Run lint before wrapping up", result.Records[0].Title) + require.Equal(t, StatusActive, result.Records[0].Status) + require.Equal(t, 2, result.History.GeneratedCount) + require.Equal(t, 2, result.History.ActivatedCount) +} + +func TestReconcileGeneratedRecords_LegacyPersonalRecordWithEmptyScopeValueStillMatches(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 20, 0, 0, 0, time.UTC) + existing := []MemoryRecord{ + { + ID: "legacy-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion."), + ScopeKind: ScopeKindMe, + ScopeValue: "", + Status: StatusActive, + Origin: OriginGenerated, + }, + } + generated := []MemoryRecord{ + { + ID: "lint-refresh", + Kind: KindRepoRule, + Title: "Run lint before wrapping up", + Body: "Run golangci-lint before you say the task is done.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before wrapping up", "Run golangci-lint before you say the task is done."), + ScopeKind: ScopeKindMe, + ScopeValue: "me@example.com", + Status: StatusCandidate, + Origin: OriginGenerated, + }, + } + + result := ReconcileGeneratedRecords(existing, generated, ScopeKindMe, "me@example.com", ActivationPolicyReview, now) + require.Len(t, result.Records, 1) + require.Equal(t, "legacy-lint", result.Records[0].ID) + require.Equal(t, StatusActive, result.Records[0].Status) + require.Equal(t, "Run lint before wrapping up", result.Records[0].Title) +} + +func TestTransitionRecordLifecycle_ActivatePersonalCandidate(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 21, 0, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "candidate-skill", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + ScopeKind: ScopeKindMe, + Status: StatusCandidate, + Origin: OriginGenerated, + CreatedAt: now.Add(-time.Hour), + UpdatedAt: now.Add(-time.Hour), + History: nil, + OwnerEmail: "me@example.com", + }, + } + + updated, changed, err := TransitionRecordLifecycle(records, "candidate-skill", LifecycleActionActivate, now) + require.NoError(t, err) + require.Len(t, updated, 1) + require.Equal(t, StatusActive, updated[0].Status) + require.Equal(t, now, updated[0].UpdatedAt) + require.Equal(t, now, updated[0].LastReviewedAt) + require.Len(t, updated[0].History, 1) + require.Equal(t, "activated", updated[0].History[0].Type) + require.Equal(t, StatusActive, changed.Status) +} + +func TestTransitionRecordLifecycle_PromoteRepoCandidate(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 21, 30, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "repo-candidate", + Kind: KindRepoRule, + Title: "Keep generated repo memories pending", + ScopeKind: ScopeKindRepo, + ScopeValue: "main", + Status: StatusCandidate, + Origin: OriginGenerated, + CreatedAt: now.Add(-2 * time.Hour), + UpdatedAt: now.Add(-2 * time.Hour), + }, + } + + updated, changed, err := TransitionRecordLifecycle(records, "repo-candidate", LifecycleActionPromote, now) + require.NoError(t, err) + require.Equal(t, StatusActive, updated[0].Status) + require.Equal(t, StatusActive, changed.Status) + require.Len(t, updated[0].History, 1) + require.Equal(t, "promoted", updated[0].History[0].Type) +} + +func TestTransitionRecordLifecycle_ActivateRepoCandidateReturnsError(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 22, 0, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "repo-candidate", + Kind: KindRepoRule, + Title: "Keep generated repo memories pending", + ScopeKind: ScopeKindRepo, + ScopeValue: "main", + Status: StatusCandidate, + }, + } + + updated, _, err := TransitionRecordLifecycle(records, "repo-candidate", LifecycleActionActivate, now) + require.Error(t, err) + require.Contains(t, err.Error(), "promote") + require.Equal(t, StatusCandidate, updated[0].Status) +} + +func TestTransitionRecordLifecycle_UnsuppressReturnsCandidate(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 22, 30, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "suppressed-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + ScopeKind: ScopeKindMe, + Status: StatusSuppressed, + }, + } + + updated, changed, err := TransitionRecordLifecycle(records, "suppressed-lint", LifecycleActionUnsuppress, now) + require.NoError(t, err) + require.Equal(t, StatusCandidate, updated[0].Status) + require.Equal(t, StatusCandidate, changed.Status) + require.Len(t, updated[0].History, 1) + require.Equal(t, "unsuppressed", updated[0].History[0].Type) +} + +func TestTransitionRecordLifecycle_SuppressActiveRecord(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 22, 45, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "active-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + ScopeKind: ScopeKindMe, + Status: StatusActive, + }, + } + + updated, changed, err := TransitionRecordLifecycle(records, "active-lint", LifecycleActionSuppress, now) + require.NoError(t, err) + require.Equal(t, StatusSuppressed, updated[0].Status) + require.Equal(t, StatusSuppressed, changed.Status) + require.Len(t, updated[0].History, 1) + require.Equal(t, "suppressed", updated[0].History[0].Type) +} + +func TestTransitionRecordLifecycle_ArchivePreservesHistory(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 23, 0, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "active-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + ScopeKind: ScopeKindMe, + Status: StatusActive, + History: []HistoryEvent{ + {Type: "generated", At: now.Add(-2 * time.Hour)}, + }, + }, + } + + updated, changed, err := TransitionRecordLifecycle(records, "active-lint", LifecycleActionArchive, now) + require.NoError(t, err) + require.Equal(t, StatusArchived, updated[0].Status) + require.Equal(t, StatusArchived, changed.Status) + require.Len(t, updated[0].History, 2) + require.Equal(t, "archived", updated[0].History[1].Type) +} + +func TestRecordInjectionActivity_UpdatesCountsAndLogs(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + state := &State{ + Store: &Store{ + Version: 1, + Records: []MemoryRecord{ + { + ID: "lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Status: StatusActive, + ScopeKind: ScopeKindMe, + CreatedAt: now.Add(-24 * time.Hour), + UpdatedAt: now.Add(-24 * time.Hour), + Outcome: OutcomeNeutral, + Strength: 3, + Fingerprint: "repo-rule-run-lint", + }, + }, + }, + } + + RecordInjectionActivity(state, []Match{ + { + Record: state.Store.Records[0], + Score: 27, + Reason: "keyword overlap", + }, + }, InjectionLog{ + SessionID: "sess-1", + PromptPreview: "fix the lint failure", + InjectedMemoryIDs: []string{"lint"}, + InjectedAt: now, + Reason: "keyword overlap", + }, now) + + require.Len(t, state.Store.Records, 1) + require.Equal(t, 1, state.Store.Records[0].MatchCount) + require.Equal(t, 1, state.Store.Records[0].InjectCount) + require.Equal(t, now, state.Store.Records[0].LastMatchedAt) + require.Equal(t, now, state.Store.Records[0].LastInjectedAt) + require.Len(t, state.Store.Records[0].History, 2) + require.Equal(t, "matched", state.Store.Records[0].History[0].Type) + require.Equal(t, "injected", state.Store.Records[0].History[1].Type) + require.Len(t, state.InjectionLogs, 1) + require.Equal(t, "sess-1", state.InjectionLogs[0].SessionID) +} + +func TestDeriveOutcomesFromEvidence_MarksReinforcedAndIneffective(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "reinforced", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Status: StatusActive, + Origin: OriginGenerated, + Outcome: OutcomeNeutral, + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-48 * time.Hour), + Fingerprint: "reinforced", + }, + { + ID: "ineffective", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + Body: "Add the missing retry step.", + Status: StatusActive, + Origin: OriginGenerated, + Outcome: OutcomeNeutral, + InjectCount: 3, + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-48 * time.Hour), + Fingerprint: "ineffective", + }, + } + + updated := DeriveOutcomesFromEvidence(records, []string{ + "Run lint before finishing to avoid repeat lint failures.", + "Lint still failed after edits.", + "Tighten the project skill because the retry step is still missing.", + }, now) + + require.Equal(t, OutcomeReinforced, updated[0].Outcome) + require.Equal(t, OutcomeIneffective, updated[1].Outcome) +} + +func TestPruneRecords_AppliesDefaultRules(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 26, 12, 0, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "stale-candidate", + Kind: KindRepoRule, + Title: "Pending lint rule", + Status: StatusCandidate, + Origin: OriginGenerated, + CreatedAt: now.Add(-31 * 24 * time.Hour), + UpdatedAt: now.Add(-31 * 24 * time.Hour), + Fingerprint: "stale-candidate", + }, + { + ID: "stale-active", + Kind: KindRepoRule, + Title: "Old active memory", + Status: StatusActive, + Origin: OriginGenerated, + CreatedAt: now.Add(-61 * 24 * time.Hour), + UpdatedAt: now.Add(-61 * 24 * time.Hour), + Fingerprint: "stale-active", + }, + { + ID: "ineffective-active", + Kind: KindSkillPatch, + Title: "Retry skill", + Status: StatusActive, + Origin: OriginGenerated, + Outcome: OutcomeIneffective, + InjectCount: 3, + CreatedAt: now.Add(-10 * 24 * time.Hour), + UpdatedAt: now.Add(-10 * 24 * time.Hour), + Fingerprint: "ineffective-active", + }, + { + ID: "manual-active", + Kind: KindWorkflowRule, + Title: "Personal preference", + Status: StatusActive, + Origin: OriginManual, + CreatedAt: now.Add(-90 * 24 * time.Hour), + UpdatedAt: now.Add(-90 * 24 * time.Hour), + Fingerprint: "manual-active", + }, + } + + updated, result := PruneRecords(records, now) + + require.Equal(t, StatusArchived, updated[0].Status) + require.Equal(t, StatusArchived, updated[1].Status) + require.Equal(t, StatusArchived, updated[2].Status) + require.Equal(t, StatusActive, updated[3].Status) + require.Equal(t, 3, result.ArchivedCount) +} + +func TestAddManualRecord_AddsPersonalActiveMemory(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 27, 0, 0, 0, 0, time.UTC) + records, added, err := AddManualRecord(nil, ManualRecordInput{ + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + ScopeKind: ScopeKindMe, + ScopeValue: "me@example.com", + OwnerEmail: "me@example.com", + }, now) + require.NoError(t, err) + require.Len(t, records, 1) + require.Equal(t, StatusActive, records[0].Status) + require.Equal(t, OriginManual, records[0].Origin) + require.Equal(t, ScopeKindMe, records[0].ScopeKind) + require.Equal(t, "me@example.com", records[0].ScopeValue) + require.Equal(t, "me@example.com", records[0].OwnerEmail) + require.NotEmpty(t, records[0].Fingerprint) + require.Len(t, records[0].History, 1) + require.Equal(t, "added", records[0].History[0].Type) + require.Equal(t, StatusActive, added.Status) +} + +func TestAddManualRecord_DedupesExistingScopedFingerprint(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 27, 0, 30, 0, 0, time.UTC) + records, added, err := AddManualRecord([]MemoryRecord{ + { + ID: "suppressed-lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Fingerprint: fingerprintForRecord(KindRepoRule, "Run lint before finishing", "Run golangci-lint before claiming completion."), + ScopeKind: ScopeKindMe, + ScopeValue: "me@example.com", + Status: StatusSuppressed, + Origin: OriginGenerated, + CreatedAt: now.Add(-24 * time.Hour), + UpdatedAt: now.Add(-24 * time.Hour), + }, + }, ManualRecordInput{ + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + ScopeKind: ScopeKindMe, + ScopeValue: "me@example.com", + OwnerEmail: "me@example.com", + }, now) + require.NoError(t, err) + require.Len(t, records, 1) + require.Equal(t, "suppressed-lint", records[0].ID) + require.Equal(t, StatusActive, records[0].Status) + require.Equal(t, OriginManual, records[0].Origin) + require.Equal(t, "me@example.com", records[0].OwnerEmail) + require.Len(t, records[0].History, 1) + require.Equal(t, "added", records[0].History[0].Type) + require.Equal(t, "suppressed-lint", added.ID) +} + +func TestRecordInjectionActivity_UpdatesMatchedAndInjectedCounts(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 27, 1, 0, 0, 0, time.UTC) + state := &State{ + Store: &Store{ + Records: []MemoryRecord{ + { + ID: "lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + ScopeKind: ScopeKindMe, + Status: StatusActive, + }, + }, + }, + } + + RecordInjectionActivity(state, []Match{ + { + Record: MemoryRecord{ID: "lint"}, + Score: 4, + Reason: "keyword overlap", + }, + }, InjectionLog{ + SessionID: "sess-1", + PromptPreview: "fix the lint failure", + InjectedMemoryIDs: []string{"lint"}, + InjectedAt: now, + Reason: "keyword overlap", + }, now) + + require.Equal(t, 1, state.Store.Records[0].MatchCount) + require.Equal(t, 1, state.Store.Records[0].InjectCount) + require.Equal(t, now, state.Store.Records[0].LastMatchedAt) + require.Equal(t, now, state.Store.Records[0].LastInjectedAt) + require.Len(t, state.InjectionLogs, 1) +} + +func TestDeriveOutcomes_MarksReinforcedAndIneffective(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 27, 1, 30, 0, 0, time.UTC) + records := []MemoryRecord{ + { + ID: "lint", + Kind: KindRepoRule, + Title: "Run lint before finishing", + Body: "Run golangci-lint before claiming completion.", + Origin: OriginGenerated, + InjectCount: 2, + Status: StatusActive, + }, + { + ID: "skill", + Kind: KindSkillPatch, + Title: "Tighten the project skill", + Body: "Add the missing retry step to the project skill.", + Origin: OriginGenerated, + Status: StatusCandidate, + }, + { + ID: "manual", + Kind: KindWorkflowRule, + Title: "Keep commit subjects concise", + Body: "Use short imperative commit subjects.", + Origin: OriginManual, + Status: StatusActive, + }, + } + sessions := []insightsdb.SessionRow{ + { + Friction: []string{"lint failed again after the agent finished"}, + Facets: facets.SessionFacets{ + SkillSignals: []facets.SkillSignal{ + {SkillName: "project skill", Friction: []string{"missing retry step in the project skill"}}, + }, + }, + }, + } + + updated := DeriveOutcomes(records, sessions, now) + require.Equal(t, OutcomeIneffective, updated[0].Outcome) + require.Equal(t, OutcomeReinforced, updated[1].Outcome) + require.Equal(t, OutcomeNeutral, updated[2].Outcome) +} + +func TestPruneRecords_ArchivesEligibleGeneratedRecordsButSkipsManual(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 27, 2, 0, 0, 0, time.UTC) + updated, result := PruneRecords([]MemoryRecord{ + { + ID: "old-candidate", + Kind: KindRepoRule, + Title: "Old candidate", + Origin: OriginGenerated, + Status: StatusCandidate, + CreatedAt: now.Add(-40 * 24 * time.Hour), + UpdatedAt: now.Add(-40 * 24 * time.Hour), + }, + { + ID: "stale-active", + Kind: KindRepoRule, + Title: "Stale active", + Origin: OriginGenerated, + Status: StatusActive, + CreatedAt: now.Add(-70 * 24 * time.Hour), + UpdatedAt: now.Add(-70 * 24 * time.Hour), + }, + { + ID: "ineffective", + Kind: KindRepoRule, + Title: "Ineffective active", + Origin: OriginGenerated, + Status: StatusActive, + InjectCount: 3, + Outcome: OutcomeIneffective, + CreatedAt: now.Add(-10 * 24 * time.Hour), + UpdatedAt: now.Add(-10 * 24 * time.Hour), + }, + { + ID: "manual-active", + Kind: KindWorkflowRule, + Title: "Manual rule", + Origin: OriginManual, + Status: StatusActive, + CreatedAt: now.Add(-100 * 24 * time.Hour), + UpdatedAt: now.Add(-100 * 24 * time.Hour), + }, + }, now) + + require.Equal(t, StatusArchived, updated[0].Status) + require.Equal(t, StatusArchived, updated[1].Status) + require.Equal(t, StatusArchived, updated[2].Status) + require.Equal(t, StatusActive, updated[3].Status) + require.Equal(t, 3, result.ArchivedCount) +} diff --git a/cmd/entire/cli/memorylooptui/keys.go b/cmd/entire/cli/memorylooptui/keys.go new file mode 100644 index 000000000..9340bdf80 --- /dev/null +++ b/cmd/entire/cli/memorylooptui/keys.go @@ -0,0 +1,99 @@ +package memorylooptui + +import "github.com/charmbracelet/bubbles/key" + +type globalKeys struct { + TabNext key.Binding + TabPrev key.Binding + Tab1 key.Binding + Tab2 key.Binding + Tab3 key.Binding + Tab4 key.Binding + Help key.Binding + Quit key.Binding +} + +type memoriesKeys struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Activate key.Binding + Promote key.Binding + Suppress key.Binding + Unsuppress key.Binding + Archive key.Binding + Prune key.Binding + Filter key.Binding + Search key.Binding + New key.Binding + Escape key.Binding +} + +type injectionKeys struct { + Up key.Binding + Down key.Binding + Focus key.Binding + Enter key.Binding + Escape key.Binding +} + +type historyKeys struct { + Up key.Binding + Down key.Binding + Refresh key.Binding +} + +type settingsKeys struct { + Mode key.Binding + Policy key.Binding + MaxUp key.Binding + MaxDown key.Binding +} + +var globalKeyMap = globalKeys{ + TabNext: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), + TabPrev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), + Tab1: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "memories")), + Tab2: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "injection")), + Tab3: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "history")), + Tab4: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "settings")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), +} + +var memoriesKeyMap = memoriesKeys{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down/j", "down")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "toggle detail")), + Activate: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "activate")), + Promote: key.NewBinding(key.WithKeys("P"), key.WithHelp("P", "promote")), + Suppress: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "suppress")), + Unsuppress: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "unsuppress")), + Archive: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "archive")), + Prune: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "prune")), + Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "cycle filter")), + Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + New: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new memory")), + Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), +} + +var injectionKeyMap = injectionKeys{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down/j", "down")), + Focus: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "focus input")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "test prompt")), + Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "unfocus")), +} + +var historyKeyMap = historyKeys{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down/j", "down")), + Refresh: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "refresh")), +} + +var settingsKeyMap = settingsKeys{ + Mode: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "cycle mode")), + Policy: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "cycle policy")), + MaxUp: key.NewBinding(key.WithKeys("+", "="), key.WithHelp("+", "increase max")), + MaxDown: key.NewBinding(key.WithKeys("-"), key.WithHelp("-", "decrease max")), +} diff --git a/cmd/entire/cli/memorylooptui/messages.go b/cmd/entire/cli/memorylooptui/messages.go new file mode 100644 index 000000000..09e576a37 --- /dev/null +++ b/cmd/entire/cli/memorylooptui/messages.go @@ -0,0 +1,68 @@ +package memorylooptui + +import ( + "github.com/entireio/cli/cmd/entire/cli/memoryloop" +) + +// stateLoadedMsg is sent when the memoryloop state is loaded from disk. +type stateLoadedMsg struct { + state *memoryloop.State + err error +} + +// lifecycleActionMsg requests a lifecycle transition on a memory record. +type lifecycleActionMsg struct { + id string + action memoryloop.LifecycleAction +} + +// addMemoryMsg requests adding a new manual memory record. +type addMemoryMsg struct { + input memoryloop.ManualRecordInput +} + +// pruneMsg requests pruning stale/ineffective records. +type pruneMsg struct{} + +// settingsChangedMsg indicates mode, policy, or max_injected was changed. +type settingsChangedMsg struct { + mode *memoryloop.Mode + activationPolicy *memoryloop.ActivationPolicy + maxInjected *int +} + +// testPromptMsg requests a prompt relevance test. +type testPromptMsg struct { + prompt string +} + +// testPromptResultMsg contains the results of a prompt test. +type testPromptResultMsg struct { + matches []memoryloop.Match +} + +// refreshStartedMsg indicates a refresh has begun. +type refreshStartedMsg struct{} + +// refreshProgressMsg reports refresh progress text. +// +//nolint:unused // used in later task (history tab implementation) +type refreshProgressMsg struct { + text string +} + +// refreshDoneMsg indicates a refresh has completed. +// +//nolint:unused // used in later task (history tab implementation) +type refreshDoneMsg struct { + state *memoryloop.State + err error +} + +// errorFlashMsg shows a temporary error message in the status bar. +type errorFlashMsg struct { + text string +} + +// clearErrorMsg clears the error flash after a timeout. +type clearErrorMsg struct{} diff --git a/cmd/entire/cli/memorylooptui/render.go b/cmd/entire/cli/memorylooptui/render.go new file mode 100644 index 000000000..85bbe0d82 --- /dev/null +++ b/cmd/entire/cli/memorylooptui/render.go @@ -0,0 +1,137 @@ +package memorylooptui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" +) + +var tabNames = [4]string{"Memories", "Injection", "History", "Settings"} + +func renderTabBar(s tuiStyles, activeTab int, width int, mode memoryloop.Mode, policy memoryloop.ActivationPolicy) string { + var b strings.Builder + + // App title + b.WriteString(s.render(s.appTitle, "MEMORY LOOP")) + b.WriteString(" ") + + // Track position for underline + titleWidth := len("MEMORY LOOP") + 3 // raw width of title + spacing + activeStart := 0 + activeWidth := 0 + pos := titleWidth + + for i, name := range tabNames { + label := fmt.Sprintf("%d %s", i+1, name) + if i == activeTab { + activeStart = pos + activeWidth = len(label) + b.WriteString(s.render(s.tabActive, label)) + } else { + b.WriteString(s.render(s.tabInactive, label)) + } + pos += len(label) + if i < len(tabNames)-1 { + b.WriteString(" ") + pos += 3 + } + } + + // Right-align mode and policy indicators + left := b.String() + modeStr := s.render(s.active, fmt.Sprintf("● %s", mode)) + policyStr := s.render(s.dim, fmt.Sprintf("· %s", policy)) + right := modeStr + " " + policyStr + + leftLen := lipglossWidth(left) + rightLen := lipglossWidth(right) + padding := width - leftLen - rightLen + if padding < 1 { + padding = 1 + } + + line1 := left + strings.Repeat(" ", padding) + right + + // Underline row: amber ─ chars aligned under the active tab + line2 := strings.Repeat(" ", activeStart) + + s.render(s.tabUnderline, strings.Repeat("─", activeWidth)) + + return line1 + "\n" + line2 +} + +func renderStatusBar(s tuiStyles, hints string, info string, width int) string { + hintsLen := len(hints) + infoLen := len(info) + padding := width - hintsLen - infoLen + if padding < 1 { + padding = 1 + } + return s.render(s.statusBar, hints) + strings.Repeat(" ", padding) + s.render(s.dim, info) +} + +func renderStrengthBar(strength int) string { + if strength < 0 { + strength = 0 + } + if strength > 5 { + strength = 5 + } + filled := strings.Repeat("\u2588", strength) + empty := strings.Repeat("\u2591", 5-strength) + return filled + empty +} + +func statusDot(s tuiStyles, status memoryloop.Status) string { + switch status { + case memoryloop.StatusActive: + return s.render(s.active, "\u25cf") + case memoryloop.StatusCandidate: + return s.render(s.candidate, "\u25cb") + case memoryloop.StatusSuppressed: + return s.render(s.suppressed, "\u2715") + case memoryloop.StatusArchived: + return s.render(s.archived, "\u25cc") + default: + return " " + } +} + +func kindStyle(s tuiStyles, kind memoryloop.Kind) func(string) string { + switch kind { + case memoryloop.KindRepoRule, memoryloop.KindWorkflowRule: + return func(t string) string { return s.render(s.repoRule, t) } + case memoryloop.KindAgentInstruction: + return func(t string) string { return s.render(s.agentInstruction, t) } + case memoryloop.KindSkillPatch: + return func(t string) string { return s.render(s.skillPatch, t) } + case memoryloop.KindAntiPattern: + return func(t string) string { return s.render(s.antiPattern, t) } + default: + return func(t string) string { return t } + } +} + +func timeAgo(t time.Time) string { + if t.IsZero() { + return "never" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +// lipglossWidth returns the visible width of a string, correctly handling ANSI sequences. +func lipglossWidth(s string) int { + return lipgloss.Width(s) +} diff --git a/cmd/entire/cli/memorylooptui/render_test.go b/cmd/entire/cli/memorylooptui/render_test.go new file mode 100644 index 000000000..8263e573e --- /dev/null +++ b/cmd/entire/cli/memorylooptui/render_test.go @@ -0,0 +1,109 @@ +package memorylooptui + +import ( + "context" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/memoryloop" + "github.com/stretchr/testify/require" +) + +func TestRootView_IncludesAppTitleAndSummaryCards(t *testing.T) { + t.Parallel() + + out := newRootModelForStyleTest().View() + + require.Contains(t, out, "MEMORY LOOP") + require.Contains(t, out, "ACTIVE") + require.Contains(t, out, "CANDIDATE") +} + +func TestMemoriesView_RendersDetailsPanelLabel(t *testing.T) { + t.Parallel() + + out := newRootModelForStyleTest().View() + + require.Contains(t, out, "DETAILS") +} + +func newRootModelForStyleTest() rootModel { + styles := newStyles() + m := rootModel{ + ctx: context.Background(), + styles: styles, + width: 100, + height: 40, + memoriesTab: newMemoriesModel(styles), + injectionTab: newInjectionModel(styles), + historyTab: newHistoryModel(styles), + settingsTab: settingsModel{styles: styles}, + state: sampleStateForStyleTest(), + } + m.pushState() + m.memoriesTab.setSize(m.width, m.contentHeight()) + m.injectionTab.setSize(m.width, m.contentHeight()) + m.historyTab.setSize(m.width, m.contentHeight()) + m.settingsTab.setSize(m.width, m.contentHeight()) + return m +} + +func sampleStateForStyleTest() *memoryloop.State { + now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + + return &memoryloop.State{ + Store: &memoryloop.Store{ + Version: 1, + GeneratedAt: now, + SourceWindow: 20, + Mode: memoryloop.ModeAuto, + ActivationPolicy: memoryloop.ActivationPolicyReview, + InjectionEnabled: true, + MaxInjected: 3, + Records: []memoryloop.MemoryRecord{ + { + ID: "memory-1", + Kind: memoryloop.KindWorkflowRule, + Title: "Run tests before merging", + Body: "Use focused package tests before broader verification.", + Why: "Keeps risky changes scoped before wider verification.", + Strength: 4, + Status: memoryloop.StatusActive, + ScopeKind: memoryloop.ScopeKindMe, + Origin: memoryloop.OriginManual, + CreatedAt: now.Add(-2 * time.Hour), + UpdatedAt: now.Add(-time.Hour), + LastInjectedAt: now.Add(-30 * time.Minute), + InjectCount: 3, + MatchCount: 2, + Outcome: memoryloop.OutcomeReinforced, + }, + { + ID: "memory-2", + Kind: memoryloop.KindRepoRule, + Title: "Keep repo memory reviewable", + Body: "Repo-scoped generated memories should stay candidate until promoted.", + Strength: 3, + Status: memoryloop.StatusCandidate, + ScopeKind: memoryloop.ScopeKindRepo, + ScopeValue: "entireio/cli", + Origin: memoryloop.OriginGenerated, + CreatedAt: now.Add(-6 * time.Hour), + UpdatedAt: now.Add(-90 * time.Minute), + InjectCount: 0, + MatchCount: 1, + Outcome: memoryloop.OutcomeNeutral, + }, + }, + }, + InjectionLogs: []memoryloop.InjectionLog{ + { + SessionID: "session-1", + PromptPreview: "please fix the failing test", + InjectedMemoryIDs: []string{"memory-1"}, + InjectedAt: now.Add(-20 * time.Minute), + Reason: "workflow guidance", + }, + }, + } +} diff --git a/cmd/entire/cli/memorylooptui/root.go b/cmd/entire/cli/memorylooptui/root.go new file mode 100644 index 000000000..13cd511e9 --- /dev/null +++ b/cmd/entire/cli/memorylooptui/root.go @@ -0,0 +1,404 @@ +package memorylooptui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" +) + +const ( + tabMemories = 0 + tabInjection = 1 + tabHistory = 2 + tabSettings = 3 + maxWidth = 120 +) + +//nolint:recvcheck // bubbletea pattern: value receivers for interface, pointer for pushState mutation +type rootModel struct { + ctx context.Context + activeTab int + state *memoryloop.State + styles tuiStyles + width int + height int + showHelp bool + isRefreshing bool + spinner spinner.Model + err error + errFlash string + + memoriesTab memoriesModel + injectionTab injectionModel + historyTab historyModel + settingsTab settingsModel +} + +// Run launches the TUI program. +func Run(ctx context.Context) error { + s := spinner.New() + s.Spinner = spinner.Dot + + styles := newStyles() + m := rootModel{ + ctx: ctx, + styles: styles, + width: maxWidth, // will be updated by tea.WindowSizeMsg + spinner: s, + memoriesTab: newMemoriesModel(styles), + injectionTab: newInjectionModel(styles), + historyTab: newHistoryModel(styles), + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() + if err != nil { + return fmt.Errorf("run TUI: %w", err) + } + return nil +} + +func (m rootModel) Init() tea.Cmd { + return func() tea.Msg { + state, err := memoryloop.LoadState(m.ctx) + return stateLoadedMsg{state: state, err: err} + } +} + +func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = min(msg.Width, maxWidth) + m.height = msg.Height + m.memoriesTab.setSize(m.width, m.contentHeight()) + m.injectionTab.setSize(m.width, m.contentHeight()) + m.historyTab.setSize(m.width, m.contentHeight()) + m.settingsTab.setSize(m.width, m.contentHeight()) + return m, nil + + case tea.KeyMsg: + // Allow ctrl+c to always quit, even when a sub-model captures input. + if key.Matches(msg, globalKeyMap.Quit) && msg.String() == "ctrl+c" { + return m, tea.Quit + } + + // When a sub-model is capturing input (add form, search, text input), + // skip global key handling so Tab/Esc/number keys reach the sub-model. + tabCapturesInput := (m.activeTab == tabMemories && m.memoriesTab.capturesInput()) || + (m.activeTab == tabInjection && m.injectionTab.inputFocus) + + if !tabCapturesInput { + // Global keys -- check before delegating to tabs + switch { + case key.Matches(msg, globalKeyMap.Quit): + return m, tea.Quit + case key.Matches(msg, globalKeyMap.Help): + m.showHelp = !m.showHelp + return m, nil + case key.Matches(msg, globalKeyMap.TabNext): + m.activeTab = (m.activeTab + 1) % 4 + return m, nil + case key.Matches(msg, globalKeyMap.TabPrev): + m.activeTab = (m.activeTab + 3) % 4 + return m, nil + case key.Matches(msg, globalKeyMap.Tab1): + m.activeTab = tabMemories + return m, nil + case key.Matches(msg, globalKeyMap.Tab2): + m.activeTab = tabInjection + return m, nil + case key.Matches(msg, globalKeyMap.Tab3): + m.activeTab = tabHistory + return m, nil + case key.Matches(msg, globalKeyMap.Tab4): + m.activeTab = tabSettings + return m, nil + } + } + + case stateLoadedMsg: + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.state = msg.state + m.pushState() + return m, nil + + case lifecycleActionMsg: + return m.handleLifecycleAction(msg) + + case addMemoryMsg: + return m.handleAddMemory(msg) + + case pruneMsg: + return m.handlePrune() + + case settingsChangedMsg: + return m.handleSettingsChanged(msg) + + case testPromptMsg: + if m.state == nil || m.state.Store == nil { + return m, nil + } + matches := memoryloop.SelectRelevant(*m.state.Store, msg.prompt, time.Now()) + return m, func() tea.Msg { return testPromptResultMsg{matches: matches} } + + case refreshStartedMsg: + if m.isRefreshing { + return m, nil + } + m.isRefreshing = true + // Full async refresh will be added in a later task. + return m, func() tea.Msg { + return errorFlashMsg{text: "Refresh not yet implemented in TUI. Use: entire memory-loop refresh"} + } + + case errorFlashMsg: + m.errFlash = msg.text + return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearErrorMsg{} }) + + case clearErrorMsg: + m.errFlash = "" + return m, nil + } + + // Delegate to active tab + var cmd tea.Cmd + switch m.activeTab { + case tabMemories: + m.memoriesTab, cmd = m.memoriesTab.update(msg) + case tabInjection: + m.injectionTab, cmd = m.injectionTab.update(msg) + case tabHistory: + m.historyTab, cmd = m.historyTab.update(msg) + case tabSettings: + m.settingsTab, cmd = m.settingsTab.update(msg) + } + if cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m rootModel) View() string { + if m.err != nil { + return fmt.Sprintf("Error loading memory store: %v\nCheck .entire/memory-loop.json", m.err) + } + if m.state == nil { + return "Loading..." + } + + var b strings.Builder + + // Tab bar + mode := memoryloop.ModeOff + policy := memoryloop.ActivationPolicyReview + if m.state.Store != nil { + mode = m.state.Store.Mode + policy = m.state.Store.ActivationPolicy + } + b.WriteString(renderTabBar(m.styles, m.activeTab, m.width, mode, policy)) + b.WriteString("\n") + + // Content area + if m.showHelp { + b.WriteString(m.renderHelp()) + } else { + switch m.activeTab { + case tabMemories: + b.WriteString(m.memoriesTab.view()) + case tabInjection: + b.WriteString(m.injectionTab.view()) + case tabHistory: + b.WriteString(m.historyTab.view()) + case tabSettings: + b.WriteString(m.settingsTab.view()) + } + } + + // Status bar + b.WriteString("\n") + if m.errFlash != "" { + b.WriteString(m.styles.render(m.styles.errorFlash, m.errFlash)) + } else { + hints := m.activeTabHints() + info := m.activeTabInfo() + b.WriteString(renderStatusBar(m.styles, hints, info, m.width)) + } + + return b.String() +} + +func (m rootModel) contentHeight() int { + // Total height minus tab bar (2) and status bar (1) and newlines (2) + h := m.height - 5 + if h < 5 { + h = 5 + } + return h +} + +func (m *rootModel) pushState() { + m.memoriesTab.setState(m.state) + m.injectionTab.setState(m.state) + m.historyTab.setState(m.state) + m.settingsTab.setState(m.state) +} + +func (m rootModel) saveState() error { + return memoryloop.SaveState(m.ctx, m.state) //nolint:wrapcheck // internal helper, callers wrap +} + +func (m rootModel) handleLifecycleAction(msg lifecycleActionMsg) (tea.Model, tea.Cmd) { + if m.state == nil || m.state.Store == nil || m.isRefreshing { + return m, nil + } + updated, _, err := memoryloop.TransitionRecordLifecycle(m.state.Store.Records, msg.id, msg.action, time.Now()) + if err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: err.Error()} } + } + m.state.Store.Records = updated + if err := m.saveState(); err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: fmt.Sprintf("save failed: %v", err)} } + } + m.memoriesTab.setState(m.state) + m.injectionTab.setState(m.state) + m.historyTab.setState(m.state) + m.settingsTab.setState(m.state) + return m, nil +} + +func (m rootModel) handleAddMemory(msg addMemoryMsg) (tea.Model, tea.Cmd) { + if m.state == nil || m.state.Store == nil || m.isRefreshing { + return m, nil + } + updated, _, err := memoryloop.AddManualRecord(m.state.Store.Records, msg.input, time.Now()) + if err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: err.Error()} } + } + m.state.Store.Records = updated + if err := m.saveState(); err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: fmt.Sprintf("save failed: %v", err)} } + } + m.memoriesTab.setState(m.state) + m.injectionTab.setState(m.state) + m.historyTab.setState(m.state) + m.settingsTab.setState(m.state) + return m, nil +} + +func (m rootModel) handlePrune() (tea.Model, tea.Cmd) { + if m.state == nil || m.state.Store == nil || m.isRefreshing { + return m, nil + } + updated, result := memoryloop.PruneRecords(m.state.Store.Records, time.Now()) + m.state.Store.Records = updated + if err := m.saveState(); err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: fmt.Sprintf("save failed: %v", err)} } + } + m.memoriesTab.setState(m.state) + m.injectionTab.setState(m.state) + m.historyTab.setState(m.state) + m.settingsTab.setState(m.state) + msg := fmt.Sprintf("Pruned %d records", result.ArchivedCount) + return m, func() tea.Msg { return errorFlashMsg{text: msg} } +} + +func (m rootModel) handleSettingsChanged(msg settingsChangedMsg) (tea.Model, tea.Cmd) { + if m.state == nil || m.state.Store == nil { + return m, nil + } + if msg.mode != nil { + m.state.Store.Mode = *msg.mode + } + if msg.activationPolicy != nil { + m.state.Store.ActivationPolicy = *msg.activationPolicy + } + if msg.maxInjected != nil { + m.state.Store.MaxInjected = *msg.maxInjected + } + if err := m.saveState(); err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: fmt.Sprintf("save failed: %v", err)} } + } + m.memoriesTab.setState(m.state) + m.injectionTab.setState(m.state) + m.historyTab.setState(m.state) + m.settingsTab.setState(m.state) + return m, nil +} + +func (m rootModel) activeTabHints() string { + switch m.activeTab { + case tabMemories: + return "j/k navigate · enter expand · f filter · a activate · s suppress · x archive · n new · ? help" + case tabInjection: + return "i focus input · enter test · esc unfocus · j/k navigate · ? help" + case tabHistory: + return "j/k navigate · R refresh · ? help" + case tabSettings: + return "m mode · p policy · +/- max injected · ? help" + default: + return "? help · q quit" + } +} + +func (m rootModel) activeTabInfo() string { + if m.state == nil || m.state.Store == nil { + return "" + } + switch m.activeTab { + case tabMemories: + return fmt.Sprintf("%d records", len(m.state.Store.Records)) + case tabInjection: + return fmt.Sprintf("%d logs", len(m.state.InjectionLogs)) + case tabHistory: + return fmt.Sprintf("%d refreshes", len(m.state.Store.RefreshHistory)) + default: + return "" + } +} + +func (m rootModel) renderHelp() string { + var b strings.Builder + b.WriteString(m.styles.render(m.styles.bold, "Keyboard Shortcuts")) + b.WriteString("\n\n") + + b.WriteString(m.styles.render(m.styles.title, "Global")) + b.WriteString("\n") + b.WriteString(" Tab/Shift+Tab cycle tabs 1-4 jump to tab\n") + b.WriteString(" q quit ? toggle help\n") + b.WriteString("\n") + + b.WriteString(m.styles.render(m.styles.title, "Memories")) + b.WriteString("\n") + b.WriteString(" j/k navigate enter toggle detail f filter / search\n") + b.WriteString(" a activate P promote s suppress u unsuppress\n") + b.WriteString(" x archive D prune n new memory\n") + b.WriteString("\n") + + b.WriteString(m.styles.render(m.styles.title, "Injection")) + b.WriteString("\n") + b.WriteString(" i focus input enter test prompt esc unfocus j/k navigate\n") + b.WriteString("\n") + + b.WriteString(m.styles.render(m.styles.title, "History")) + b.WriteString("\n") + b.WriteString(" j/k navigate R trigger refresh\n") + b.WriteString("\n") + + b.WriteString(m.styles.render(m.styles.title, "Settings")) + b.WriteString("\n") + b.WriteString(" m cycle mode p cycle policy +/- max injected\n") + + return b.String() +} diff --git a/cmd/entire/cli/memorylooptui/styles.go b/cmd/entire/cli/memorylooptui/styles.go new file mode 100644 index 000000000..49ec06cfe --- /dev/null +++ b/cmd/entire/cli/memorylooptui/styles.go @@ -0,0 +1,100 @@ +package memorylooptui + +import ( + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/termstyle" +) + +type tuiStyles struct { + colorEnabled bool + + // Status colors + active lipgloss.Style + candidate lipgloss.Style + suppressed lipgloss.Style + archived lipgloss.Style + + // Kind colors + repoRule lipgloss.Style + workflowRule lipgloss.Style + agentInstruction lipgloss.Style + skillPatch lipgloss.Style + antiPattern lipgloss.Style + + // UI elements + title lipgloss.Style + selected lipgloss.Style + dim lipgloss.Style + bold lipgloss.Style + tabActive lipgloss.Style + tabInactive lipgloss.Style + statusBar lipgloss.Style + errorFlash lipgloss.Style + + // Tab bar & filter chips + appTitle lipgloss.Style + tabUnderline lipgloss.Style + filterChipActive lipgloss.Style + filterChipInactive lipgloss.Style + sectionHeader lipgloss.Style +} + +func newStyles() tuiStyles { + useColor := termstyle.ShouldUseColor(os.Stdout) + + s := tuiStyles{colorEnabled: useColor} + + if !useColor { + return s + } + + green := lipgloss.Color("2") + red := lipgloss.Color("1") + yellow := lipgloss.Color("3") + gray := lipgloss.Color("8") + amber := lipgloss.Color("214") + purple := lipgloss.Color("5") + + s.active = lipgloss.NewStyle().Foreground(green) + s.candidate = lipgloss.NewStyle().Foreground(yellow) + s.suppressed = lipgloss.NewStyle().Foreground(red) + s.archived = lipgloss.NewStyle().Foreground(gray) + + s.repoRule = lipgloss.NewStyle().Foreground(green) + s.workflowRule = lipgloss.NewStyle().Foreground(green) + s.agentInstruction = lipgloss.NewStyle().Foreground(amber) + s.skillPatch = lipgloss.NewStyle().Foreground(purple) + s.antiPattern = lipgloss.NewStyle().Foreground(red) + + s.title = lipgloss.NewStyle().Foreground(amber) + s.selected = lipgloss.NewStyle().Foreground(amber) + s.dim = lipgloss.NewStyle().Faint(true) + s.bold = lipgloss.NewStyle().Bold(true) + s.tabActive = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.tabInactive = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + s.statusBar = lipgloss.NewStyle().Faint(true) + s.errorFlash = lipgloss.NewStyle().Foreground(red) + + s.appTitle = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.tabUnderline = lipgloss.NewStyle().Foreground(amber) + s.filterChipActive = lipgloss.NewStyle(). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("0")). + Bold(true). + Padding(0, 1) + s.filterChipInactive = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Padding(0, 1) + s.sectionHeader = lipgloss.NewStyle().Bold(true).Faint(true) + + return s +} + +func (s tuiStyles) render(style lipgloss.Style, text string) string { + if !s.colorEnabled { + return text + } + return style.Render(text) +} diff --git a/cmd/entire/cli/memorylooptui/tab_history.go b/cmd/entire/cli/memorylooptui/tab_history.go new file mode 100644 index 000000000..95feb2f45 --- /dev/null +++ b/cmd/entire/cli/memorylooptui/tab_history.go @@ -0,0 +1,125 @@ +package memorylooptui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type historyModel struct { + state *memoryloop.State + styles tuiStyles + width int + height int + table table.Model +} + +func newHistoryModel(s tuiStyles) historyModel { + columns := []table.Column{ + {Title: "Time", Width: 12}, + {Title: "Scope", Width: 16}, + {Title: "Generated", Width: 9}, + {Title: "Activated", Width: 9}, + {Title: "Candidate", Width: 9}, + {Title: "Window", Width: 7}, + } + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + ) + st := table.DefaultStyles() + st.Header = st.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(false). + Faint(true) + st.Selected = st.Selected. + Foreground(lipgloss.Color("214")). + Bold(false) + t.SetStyles(st) + + return historyModel{ + styles: s, + table: t, + } +} + +func (m *historyModel) setState(state *memoryloop.State) { + m.state = state + m.rebuildTable() +} + +func (m *historyModel) setSize(w, h int) { + m.width = w + m.height = h + m.table.SetWidth(w) + m.table.SetHeight(h - 6) +} + +func (m *historyModel) rebuildTable() { + if m.state == nil || m.state.Store == nil { + m.table.SetRows(nil) + return + } + history := m.state.Store.RefreshHistory + rows := make([]table.Row, len(history)) + for i, h := range history { + scope := h.Scope + if h.ScopeValue != "" { + scope += ":" + truncate(h.ScopeValue, 12) + } + rows[i] = table.Row{ + timeAgo(h.At), + scope, + fmt.Sprintf("+%d", h.GeneratedCount), + strconv.Itoa(h.ActivatedCount), + strconv.Itoa(h.CandidateCount), + strconv.Itoa(h.SourceWindow), + } + } + m.table.SetRows(rows) +} + +func (m historyModel) update(msg tea.Msg) (historyModel, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if key.Matches(keyMsg, historyKeyMap.Refresh) { + // Refresh is an async operation handled by root model. + // For now, emit refreshStartedMsg so root can orchestrate. + return m, func() tea.Msg { return refreshStartedMsg{} } + } + } + + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m historyModel) view() string { + var b strings.Builder + + // Section description + b.WriteString("\n ") + b.WriteString(m.styles.render(m.styles.sectionHeader, "REFRESH HISTORY")) + b.WriteString("\n ") + b.WriteString(m.styles.render(m.styles.dim, + "Each refresh analyzes recent sessions to generate, update, and prune memories.")) + b.WriteString("\n ") + b.WriteString(m.styles.render(m.styles.dim, + "Run: entire memory-loop refresh")) + b.WriteString("\n\n") + + if m.state == nil || m.state.Store == nil || len(m.state.Store.RefreshHistory) == 0 { + b.WriteString(" No refresh history yet. Press R to run your first refresh.\n") + return b.String() + } + + b.WriteString(m.table.View()) + return b.String() +} diff --git a/cmd/entire/cli/memorylooptui/tab_injection.go b/cmd/entire/cli/memorylooptui/tab_injection.go new file mode 100644 index 000000000..cc0be43cb --- /dev/null +++ b/cmd/entire/cli/memorylooptui/tab_injection.go @@ -0,0 +1,238 @@ +package memorylooptui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type injectionModel struct { + state *memoryloop.State + styles tuiStyles + width int + height int + logTable table.Model + input textinput.Model + inputFocus bool + matches []memoryloop.Match +} + +func newInjectionModel(s tuiStyles) injectionModel { + columns := []table.Column{ + {Title: "Time", Width: 10}, + {Title: "Session", Width: 10}, + {Title: "Count", Width: 5}, + {Title: "Prompt Preview", Width: 40}, + } + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + ) + st := table.DefaultStyles() + st.Header = st.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(false). + Faint(true) + st.Selected = st.Selected. + Foreground(lipgloss.Color("214")). + Bold(false) + t.SetStyles(st) + + ti := textinput.New() + ti.Placeholder = "type a prompt to test memory matching..." + ti.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("\u276f") + " " + ti.Width = 80 + + return injectionModel{ + styles: s, + logTable: t, + input: ti, + } +} + +func (m *injectionModel) setState(state *memoryloop.State) { + m.state = state + m.rebuildLogTable() +} + +func (m *injectionModel) setSize(w, h int) { + m.width = w + m.height = h + // Reserve: prompt tester (3) + section label (2) + detail card (12) + matches (6) + tableH := h - 23 + if tableH < 3 { + tableH = 3 + } + if tableH > 10 { + tableH = 10 // Cap to avoid pushing detail off screen + } + m.logTable.SetWidth(w) + m.logTable.SetHeight(tableH) + m.input.Width = w - 4 +} + +func (m *injectionModel) rebuildLogTable() { + if m.state == nil { + m.logTable.SetRows(nil) + return + } + logs := m.state.InjectionLogs + rows := make([]table.Row, len(logs)) + for i, l := range logs { + rows[i] = table.Row{ + timeAgo(l.InjectedAt), + truncate(l.SessionID, 10), + strconv.Itoa(len(l.InjectedMemoryIDs)), + truncate(l.PromptPreview, 40), + } + } + m.logTable.SetRows(rows) +} + +func (m injectionModel) update(msg tea.Msg) (injectionModel, tea.Cmd) { + switch msg := msg.(type) { + case testPromptResultMsg: + m.matches = msg.matches + return m, nil + + case tea.KeyMsg: + if m.inputFocus { + switch { + case key.Matches(msg, injectionKeyMap.Escape): + m.inputFocus = false + m.input.Blur() + return m, nil + case key.Matches(msg, injectionKeyMap.Enter): + prompt := m.input.Value() + if prompt != "" { + return m, func() tea.Msg { return testPromptMsg{prompt: prompt} } + } + return m, nil + } + // Delegate to text input + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + + if key.Matches(msg, injectionKeyMap.Focus) { + m.inputFocus = true + m.input.Focus() + return m, textinput.Blink + } + } + + if !m.inputFocus { + var cmd tea.Cmd + m.logTable, cmd = m.logTable.Update(msg) + return m, cmd + } + return m, nil +} + +func (m injectionModel) selectedLog() *memoryloop.InjectionLog { + cursor := m.logTable.Cursor() + if m.state == nil || cursor < 0 || cursor >= len(m.state.InjectionLogs) { + return nil + } + return &m.state.InjectionLogs[cursor] +} + +func (m injectionModel) view() string { + var b strings.Builder + + // Prompt tester section + b.WriteString("\n ") + b.WriteString(m.styles.render(m.styles.sectionHeader, "PROMPT TESTER")) + b.WriteString("\n\n") + b.WriteString(" ") + b.WriteString(m.input.View()) + b.WriteString("\n") + + // Match results in bordered card + if len(m.matches) > 0 { + var mb strings.Builder + mb.WriteString(m.styles.render(m.styles.bold, fmt.Sprintf("Matches (%d)", len(m.matches)))) + mb.WriteString("\n\n") + for i, match := range m.matches { + fmt.Fprintf(&mb, "%s %s", + m.styles.render(m.styles.title, match.Record.Title), + m.styles.render(m.styles.active, fmt.Sprintf("score: %d", match.Score))) + if match.Reason != "" { + fmt.Fprintf(&mb, "\n %s", m.styles.render(m.styles.dim, match.Reason)) + } + if i < len(m.matches)-1 { + mb.WriteString("\n\n") + } + } + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("245")). + Padding(1, 2) + if m.width > 4 { + cardStyle = cardStyle.Width(m.width - 4) + } + b.WriteString("\n") + b.WriteString(cardStyle.Render(mb.String())) + b.WriteString("\n") + } + + // Injection logs + b.WriteString("\n ") + b.WriteString(m.styles.render(m.styles.sectionHeader, "RECENT INJECTIONS")) + b.WriteString("\n\n") + + if m.state == nil || len(m.state.InjectionLogs) == 0 { + b.WriteString(" No injection logs yet. Memories inject when mode is auto.\n") + } else { + b.WriteString(m.logTable.View()) + + // Detail view for selected log entry + if log := m.selectedLog(); log != nil { + var db strings.Builder + db.WriteString(m.styles.render(m.styles.title, "Injection Detail")) + db.WriteString("\n\n") + fmt.Fprintf(&db, "%s %s", + m.styles.render(m.styles.dim, "Session:"), + log.SessionID) + fmt.Fprintf(&db, "\n%s %s", + m.styles.render(m.styles.dim, "Time:"), + timeAgo(log.InjectedAt)) + if len(log.InjectedMemoryIDs) > 0 { + fmt.Fprintf(&db, "\n%s %s", + m.styles.render(m.styles.dim, "Memories:"), + strings.Join(log.InjectedMemoryIDs, ", ")) + } + if log.Reason != "" { + fmt.Fprintf(&db, "\n%s %s", + m.styles.render(m.styles.dim, "Reason:"), + log.Reason) + } + if log.PromptPreview != "" { + fmt.Fprintf(&db, "\n\n%s\n%s", + m.styles.render(m.styles.dim, "Prompt:"), + log.PromptPreview) + } + detailCard := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("245")). + Padding(1, 2) + if m.width > 4 { + detailCard = detailCard.Width(m.width - 4) + } + b.WriteString("\n\n") + b.WriteString(detailCard.Render(db.String())) + } + } + + return b.String() +} diff --git a/cmd/entire/cli/memorylooptui/tab_memories.go b/cmd/entire/cli/memorylooptui/tab_memories.go new file mode 100644 index 000000000..2c6721386 --- /dev/null +++ b/cmd/entire/cli/memorylooptui/tab_memories.go @@ -0,0 +1,517 @@ +package memorylooptui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" +) + +type statusFilter int + +const ( + filterAll statusFilter = iota + filterActive + filterCandidate + filterSuppressed + filterArchived +) + +var filterLabels = [5]string{"ALL", "ACTIVE", "CANDIDATE", "SUPPRESSED", "ARCHIVED"} + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type memoriesModel struct { + state *memoryloop.State + styles tuiStyles + width int + height int + table table.Model + filter statusFilter + showDetail bool + searchMode bool + searchText string + records []memoryloop.MemoryRecord // filtered subset + addMode bool + addFields [4]textinput.Model // kind, title, body, scope + addFocus int +} + +func newMemoriesModel(s tuiStyles) memoriesModel { + columns := []table.Column{ + {Title: " ", Width: 2}, + {Title: "Title", Width: 30}, + {Title: "Kind", Width: 14}, + {Title: "Scope", Width: 5}, + {Title: "Str", Width: 5}, + {Title: "Outcome", Width: 11}, + {Title: "Inj", Width: 4}, + } + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + ) + st := table.DefaultStyles() + st.Header = st.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(false). + Faint(true) + st.Selected = st.Selected. + Foreground(lipgloss.Color("214")). + Bold(false) + t.SetStyles(st) + + return memoriesModel{ + styles: s, + table: t, + showDetail: true, + } +} + +// capturesInput returns true when the memories tab is handling keyboard input +// internally (add form or search mode), so the root model should skip global keys. +func (m memoriesModel) capturesInput() bool { + return m.addMode || m.searchMode +} + +func newAddFields() [4]textinput.Model { + kind := textinput.New() + kind.Placeholder = "repo_rule | workflow_rule | agent_instruction | skill_patch | anti_pattern" + kind.Prompt = "Kind: " + kind.Width = 60 + + title := textinput.New() + title.Placeholder = "memory title" + title.Prompt = "Title: " + title.Width = 60 + + body := textinput.New() + body.Placeholder = "memory body" + body.Prompt = "Body: " + body.Width = 60 + + scope := textinput.New() + scope.Placeholder = "me | repo" + scope.Prompt = "Scope: " + scope.Width = 20 + scope.SetValue("me") + + return [4]textinput.Model{kind, title, body, scope} +} + +func (m *memoriesModel) setState(state *memoryloop.State) { + m.state = state + m.rebuildTable() +} + +func (m *memoriesModel) setSize(w, h int) { + m.width = w + m.height = h + // Reserve 3 lines for filter bar + detail pane header area + tableH := h - 3 + if m.showDetail { + tableH = h / 2 + } + if tableH < 3 { + tableH = 3 + } + m.table.SetWidth(w) + m.table.SetHeight(tableH) +} + +func (m *memoriesModel) rebuildTable() { + if m.state == nil || m.state.Store == nil { + m.records = nil + m.table.SetRows(nil) + return + } + + var filtered []memoryloop.MemoryRecord + for _, r := range m.state.Store.Records { + if !m.matchesFilter(r) { + continue + } + if m.searchMode && m.searchText != "" { + if !strings.Contains(strings.ToLower(r.Title), strings.ToLower(m.searchText)) { + continue + } + } + filtered = append(filtered, r) + } + m.records = filtered + + rows := make([]table.Row, len(filtered)) + for i, r := range filtered { + rows[i] = table.Row{ + statusDotPlain(r.Status), + truncate(r.Title, 30), + string(r.Kind), + string(r.ScopeKind), + renderStrengthBar(r.Strength), + string(r.Outcome), + strconv.Itoa(r.InjectCount), + } + } + m.table.SetRows(rows) +} + +func (m memoriesModel) matchesFilter(r memoryloop.MemoryRecord) bool { + switch m.filter { + case filterAll: + return true + case filterActive: + return r.Status == memoryloop.StatusActive + case filterCandidate: + return r.Status == memoryloop.StatusCandidate + case filterSuppressed: + return r.Status == memoryloop.StatusSuppressed + case filterArchived: + return r.Status == memoryloop.StatusArchived + default: + return true + } +} + +func (m memoriesModel) selectedRecord() *memoryloop.MemoryRecord { + cursor := m.table.Cursor() + if cursor < 0 || cursor >= len(m.records) { + return nil + } + return &m.records[cursor] +} + +func (m memoriesModel) update(msg tea.Msg) (memoriesModel, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if m.addMode { + return m.updateAddForm(keyMsg) + } + + if m.searchMode { + return m.updateSearch(keyMsg) + } + + switch { + case key.Matches(keyMsg, memoriesKeyMap.New): + m.addMode = true + m.addFields = newAddFields() + m.addFocus = 0 + m.addFields[0].Focus() + return m, textinput.Blink + + case key.Matches(keyMsg, memoriesKeyMap.Filter): + m.filter = (m.filter + 1) % 5 + m.rebuildTable() + return m, nil + + case key.Matches(keyMsg, memoriesKeyMap.Search): + m.searchMode = true + m.searchText = "" + return m, nil + + case key.Matches(keyMsg, memoriesKeyMap.Enter): + m.showDetail = !m.showDetail + m.setSize(m.width, m.height) + return m, nil + + case key.Matches(keyMsg, memoriesKeyMap.Activate): + if r := m.selectedRecord(); r != nil { + return m, func() tea.Msg { + return lifecycleActionMsg{id: r.ID, action: memoryloop.LifecycleActionActivate} + } + } + + case key.Matches(keyMsg, memoriesKeyMap.Promote): + if r := m.selectedRecord(); r != nil { + return m, func() tea.Msg { + return lifecycleActionMsg{id: r.ID, action: memoryloop.LifecycleActionPromote} + } + } + + case key.Matches(keyMsg, memoriesKeyMap.Suppress): + if r := m.selectedRecord(); r != nil { + return m, func() tea.Msg { + return lifecycleActionMsg{id: r.ID, action: memoryloop.LifecycleActionSuppress} + } + } + + case key.Matches(keyMsg, memoriesKeyMap.Unsuppress): + if r := m.selectedRecord(); r != nil { + return m, func() tea.Msg { + return lifecycleActionMsg{id: r.ID, action: memoryloop.LifecycleActionUnsuppress} + } + } + + case key.Matches(keyMsg, memoriesKeyMap.Archive): + if r := m.selectedRecord(); r != nil { + return m, func() tea.Msg { + return lifecycleActionMsg{id: r.ID, action: memoryloop.LifecycleActionArchive} + } + } + + case key.Matches(keyMsg, memoriesKeyMap.Prune): + return m, func() tea.Msg { return pruneMsg{} } + } + } + + // Delegate to table for navigation + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m memoriesModel) updateSearch(msg tea.KeyMsg) (memoriesModel, tea.Cmd) { + switch { + case key.Matches(msg, memoriesKeyMap.Escape): + m.searchMode = false + m.searchText = "" + m.rebuildTable() + return m, nil + case msg.Type == tea.KeyEnter: + m.searchMode = false + return m, nil + case msg.Type == tea.KeyBackspace: + if len(m.searchText) > 0 { + runes := []rune(m.searchText) + m.searchText = string(runes[:len(runes)-1]) + m.rebuildTable() + } + return m, nil + default: + if len(msg.Runes) > 0 { + m.searchText += string(msg.Runes) + m.rebuildTable() + } + return m, nil + } +} + +func (m memoriesModel) updateAddForm(msg tea.KeyMsg) (memoriesModel, tea.Cmd) { + switch { + case key.Matches(msg, memoriesKeyMap.Escape): + m.addMode = false + return m, nil + + case msg.String() == "tab": + // Blur current, advance focus, focus next + m.addFields[m.addFocus].Blur() + m.addFocus = (m.addFocus + 1) % len(m.addFields) + m.addFields[m.addFocus].Focus() + return m, textinput.Blink + + case msg.String() == "shift+tab": + m.addFields[m.addFocus].Blur() + m.addFocus = (m.addFocus + len(m.addFields) - 1) % len(m.addFields) + m.addFields[m.addFocus].Focus() + return m, textinput.Blink + + case msg.String() == "enter": + if m.addFocus < len(m.addFields)-1 { + // Not on last field -- advance to next + m.addFields[m.addFocus].Blur() + m.addFocus++ + m.addFields[m.addFocus].Focus() + return m, textinput.Blink + } + // On last field -- submit + input := memoryloop.ManualRecordInput{ + Kind: memoryloop.Kind(m.addFields[0].Value()), + Title: m.addFields[1].Value(), + Body: m.addFields[2].Value(), + ScopeKind: memoryloop.ScopeKind(m.addFields[3].Value()), + ScopeValue: "", + } + m.addMode = false + return m, func() tea.Msg { return addMemoryMsg{input: input} } + } + + // Delegate to the focused text input for character input + var cmd tea.Cmd + m.addFields[m.addFocus], cmd = m.addFields[m.addFocus].Update(msg) + return m, cmd +} + +func (m memoriesModel) view() string { + if m.addMode { + return m.renderAddForm() + } + + if m.state == nil || m.state.Store == nil { + return "\n No memory store found. Switch to History tab and press R to refresh.\n" + } + if len(m.state.Store.Records) == 0 { + return "\n No memories yet. Press n to add one, or switch to History tab and press R to refresh.\n" + } + if len(m.records) == 0 { + return fmt.Sprintf("\n No %s memories. Press f to change filter.\n", filterLabels[m.filter]) + } + + var b strings.Builder + + // Blank line for spacing between tab bar and filter bar + b.WriteString("\n") + + // Filter bar + b.WriteString(m.renderFilterBar()) + b.WriteString("\n\n") + + // Search bar (if active) + if m.searchMode { + fmt.Fprintf(&b, " / %s\u2588\n", m.searchText) + } + + // Table + b.WriteString(m.table.View()) + + // Detail pane with spacing above + if m.showDetail { + b.WriteString("\n\n") + b.WriteString(m.renderDetail()) + } + + return b.String() +} + +func (m memoriesModel) renderAddForm() string { + var b strings.Builder + b.WriteString("\n ") + b.WriteString(m.styles.render(m.styles.title, "NEW MEMORY")) + b.WriteString("\n\n") + for i := range m.addFields { + b.WriteString(" ") + b.WriteString(m.addFields[i].View()) + b.WriteString("\n") + } + b.WriteString("\n ") + b.WriteString(m.styles.render(m.styles.dim, "Tab next field | Enter submit | Esc cancel")) + b.WriteString("\n") + return b.String() +} + +func (m memoriesModel) renderFilterBar() string { + counts := m.statusCounts() + var parts []string + for i, label := range filterLabels { + text := fmt.Sprintf("%s (%d)", label, counts[i]) + if m.styles.colorEnabled { + if statusFilter(i) == m.filter { + parts = append(parts, m.styles.filterChipActive.Render(text)) + } else { + parts = append(parts, m.styles.filterChipInactive.Render(text)) + } + } else { + if statusFilter(i) == m.filter { + parts = append(parts, "["+text+"]") + } else { + parts = append(parts, " "+text+" ") + } + } + } + return " " + strings.Join(parts, " ") +} + +func (m memoriesModel) statusCounts() [5]int { + var counts [5]int + if m.state == nil || m.state.Store == nil { + return counts + } + for _, r := range m.state.Store.Records { + counts[0]++ // all + switch r.Status { + case memoryloop.StatusActive: + counts[1]++ + case memoryloop.StatusCandidate: + counts[2]++ + case memoryloop.StatusSuppressed: + counts[3]++ + case memoryloop.StatusArchived: + counts[4]++ + } + } + return counts +} + +func (m memoriesModel) renderDetail() string { + r := m.selectedRecord() + if r == nil { + return "" + } + + var out strings.Builder + out.WriteString(" ") + out.WriteString(m.styles.render(m.styles.sectionHeader, "DETAILS")) + out.WriteString("\n") + + var b strings.Builder + // Title line + b.WriteString(m.styles.render(m.styles.title, r.Title)) + b.WriteString("\n\n") + + // Metadata line (kind, status, scope, origin) + b.WriteString(kindStyle(m.styles, r.Kind)(string(r.Kind))) + b.WriteString(" ") + b.WriteString(statusDot(m.styles, r.Status)) + b.WriteString(" ") + b.WriteString(string(r.Status)) + b.WriteString(" ") + b.WriteString(m.styles.render(m.styles.dim, string(r.ScopeKind))) + b.WriteString(" ") + b.WriteString(m.styles.render(m.styles.dim, string(r.Origin))) + + // Body + if r.Body != "" { + b.WriteString("\n\n") + b.WriteString(r.Body) + } + + // Why + if r.Why != "" { + b.WriteString("\n\n") + b.WriteString(m.styles.render(m.styles.dim, "WHY")) + b.WriteString("\n") + b.WriteString(r.Why) + } + + // Stats + b.WriteString("\n\n") + stats := fmt.Sprintf("strength: %d/5 · injected: %dx · matched: %dx · last injected: %s · created: %s", + r.Strength, r.InjectCount, r.MatchCount, timeAgo(r.LastInjectedAt), timeAgo(r.CreatedAt)) + b.WriteString(m.styles.render(m.styles.dim, stats)) + + // Wrap in bordered card + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("245")). + Padding(1, 2). + Width(m.width - 2) + out.WriteString(cardStyle.Render(b.String())) + return out.String() +} + +func statusDotPlain(status memoryloop.Status) string { + switch status { + case memoryloop.StatusActive: + return "\u25cf" + case memoryloop.StatusCandidate: + return "\u25cb" + case memoryloop.StatusSuppressed: + return "\u2715" + case memoryloop.StatusArchived: + return "\u25cc" + default: + return " " + } +} + +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen-1]) + "\u2026" +} diff --git a/cmd/entire/cli/memorylooptui/tab_settings.go b/cmd/entire/cli/memorylooptui/tab_settings.go new file mode 100644 index 000000000..1ba837720 --- /dev/null +++ b/cmd/entire/cli/memorylooptui/tab_settings.go @@ -0,0 +1,195 @@ +package memorylooptui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/memoryloop" +) + +var modeOrder = []memoryloop.Mode{memoryloop.ModeOff, memoryloop.ModeManual, memoryloop.ModeAuto} +var policyOrder = []memoryloop.ActivationPolicy{memoryloop.ActivationPolicyReview, memoryloop.ActivationPolicyAuto} + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type settingsModel struct { + state *memoryloop.State + styles tuiStyles + width int + height int +} + +func (m *settingsModel) setState(state *memoryloop.State) { m.state = state } +func (m *settingsModel) setSize(w, h int) { m.width = w; m.height = h } + +func (m settingsModel) update(msg tea.Msg) (settingsModel, tea.Cmd) { + if m.state == nil || m.state.Store == nil { + return m, nil + } + + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + var changed settingsChangedMsg + hasChange := false + + switch { + case key.Matches(keyMsg, settingsKeyMap.Mode): + next := cycleMode(m.state.Store.Mode) + changed.mode = &next + hasChange = true + + case key.Matches(keyMsg, settingsKeyMap.Policy): + next := cyclePolicy(m.state.Store.ActivationPolicy) + changed.activationPolicy = &next + hasChange = true + + case key.Matches(keyMsg, settingsKeyMap.MaxUp): + next := m.state.Store.MaxInjected + 1 + if next > 10 { + next = 10 + } + changed.maxInjected = &next + hasChange = true + + case key.Matches(keyMsg, settingsKeyMap.MaxDown): + next := m.state.Store.MaxInjected - 1 + if next < 1 { + next = 1 + } + changed.maxInjected = &next + hasChange = true + } + + if hasChange { + return m, func() tea.Msg { return changed } + } + return m, nil +} + +func (m settingsModel) view() string { + if m.state == nil || m.state.Store == nil { + return "\n No settings available.\n" + } + store := m.state.Store + + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("245")). + Padding(0, 1). + Width(m.width - 2) + + // Chip styles for selected vs unselected options + selectedChip := lipgloss.NewStyle(). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("0")). + Bold(true). + Padding(0, 1) + unselectedChip := lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Padding(0, 1) + + var b strings.Builder + b.WriteString("\n") + + // Mode card + { + var c strings.Builder + c.WriteString(m.styles.render(m.styles.bold, "Mode")) + c.WriteString(" ") + c.WriteString(m.styles.render(m.styles.dim, "Controls whether active memories inject into prompts")) + c.WriteString("\n") + for _, mode := range modeOrder { + label := string(mode) + if mode == store.Mode { + c.WriteString(selectedChip.Render(label)) + } else { + c.WriteString(unselectedChip.Render(label)) + } + c.WriteString(" ") + } + b.WriteString(cardStyle.Render(c.String())) + b.WriteString("\n") + } + + // Policy card + { + var c strings.Builder + c.WriteString(m.styles.render(m.styles.bold, "Activation Policy")) + c.WriteString(" ") + c.WriteString(m.styles.render(m.styles.dim, "What happens to newly generated memories")) + c.WriteString("\n") + for _, pol := range policyOrder { + label := string(pol) + if pol == store.ActivationPolicy { + c.WriteString(selectedChip.Render(label)) + } else { + c.WriteString(unselectedChip.Render(label)) + } + c.WriteString(" ") + } + b.WriteString(cardStyle.Render(c.String())) + b.WriteString("\n") + } + + // Max Injected card + { + var c strings.Builder + c.WriteString(m.styles.render(m.styles.bold, "Max Injected")) + c.WriteString(" ") + c.WriteString(m.styles.render(m.styles.dim, "Maximum memories per prompt injection")) + c.WriteString("\n") + numStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Bold(true) + fmt.Fprintf(&c, " ◀ %s ▶", numStyle.Render(fmt.Sprintf(" %d ", store.MaxInjected))) + b.WriteString(cardStyle.Render(c.String())) + b.WriteString("\n") + } + + // Injection status card + { + var c strings.Builder + c.WriteString(m.styles.render(m.styles.bold, "Injection")) + c.WriteString(" ") + if store.InjectionEnabled { + c.WriteString(m.styles.render(m.styles.active, "● enabled")) + } else { + c.WriteString(m.styles.render(m.styles.suppressed, "○ disabled")) + } + b.WriteString(cardStyle.Render(c.String())) + b.WriteString("\n") + } + + // Stats + b.WriteString("\n") + b.WriteString(" ") + b.WriteString(m.styles.render(m.styles.dim, + fmt.Sprintf("Last refresh: %s · Store version: %d · Source window: %d sessions", + timeAgo(store.GeneratedAt), store.Version, store.SourceWindow))) + b.WriteString("\n") + + return b.String() +} + +func cycleMode(current memoryloop.Mode) memoryloop.Mode { + for i, m := range modeOrder { + if m == current { + return modeOrder[(i+1)%len(modeOrder)] + } + } + return memoryloop.ModeOff +} + +func cyclePolicy(current memoryloop.ActivationPolicy) memoryloop.ActivationPolicy { + for i, p := range policyOrder { + if p == current { + return policyOrder[(i+1)%len(policyOrder)] + } + } + return memoryloop.ActivationPolicyReview +} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 36680b954..a83207bb6 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -95,6 +95,10 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newTraceCmd()) cmd.AddCommand(newTrailCmd()) + cmd.AddCommand(newInsightsCmd()) + cmd.AddCommand(newImproveCmd()) + cmd.AddCommand(newMemoryLoopCmd()) + cmd.AddCommand(newSkillCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index d895a9588..0765f674c 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -135,6 +135,11 @@ type State struct { // Set from hook data when the agent provides it. ModelName string `json:"model_name,omitempty"` + // OwnerName and OwnerEmail capture the git identity active when the session started. + // These fields are used for durable session attribution in shared repos/worktrees. + OwnerName string `json:"owner_name,omitempty"` + OwnerEmail string `json:"owner_email,omitempty"` + // Token usage tracking (accumulated across all checkpoints in this session) TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"` diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 49f1895c9..dbe38a7b0 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -23,6 +23,14 @@ const ( EntireSettingsLocalFile = ".entire/settings.local.json" ) +const ( + memoryLoopModeOff = "off" + memoryLoopModeManual = "manual" + memoryLoopModeAuto = "auto" + memoryLoopActivationPolicyReview = "review" + memoryLoopActivationPolicyAuto = "auto" +) + // Commit linking mode constants. const ( // CommitLinkingAlways auto-links commits to sessions without prompting. @@ -71,6 +79,13 @@ type EntireSettings struct { // plugins (entire-agent-* binaries on $PATH). Defaults to false. ExternalAgents bool `json:"external_agents,omitempty"` + // EvolveConfig configures the automatic evolution loop. + // When enabled, automatically suggests improvements after N sessions. + EvolveConfig *EvolveSettings `json:"evolve,omitempty"` + + // MemoryLoopConfig configures the repo-scoped memory loop PoC. + MemoryLoopConfig *MemoryLoopSettings `json:"memory_loop,omitempty"` + // Deprecated: no longer used. Exists to tolerate old settings files // that still contain "strategy": "auto-commit" or similar. Strategy string `json:"strategy,omitempty"` @@ -91,6 +106,93 @@ type PIISettings struct { CustomPatterns map[string]string `json:"custom_patterns,omitempty"` } +// EvolveSettings configures the automatic evolution loop that suggests +// context file improvements after a configurable number of sessions. +type EvolveSettings struct { + // Enabled activates the evolution loop. Defaults to false (opt-in). + Enabled bool `json:"enabled"` + + // SessionThreshold is the number of sessions before auto-suggesting + // improvements. Defaults to 5. + SessionThreshold int `json:"session_threshold,omitempty"` +} + +// MemoryLoopSettings configures the repo-scoped memory loop PoC. +type MemoryLoopSettings struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode,omitempty"` + ActivationPolicy string `json:"activation_policy,omitempty"` + ClaudeInjectionEnabled *bool `json:"claude_injection_enabled,omitempty"` + MaxInjected int `json:"max_injected,omitempty"` + DefaultRefreshWindow int `json:"default_refresh_window,omitempty"` +} + +// MemoryLoopConfig is the effective memory-loop configuration with defaults applied. +type MemoryLoopConfig struct { + Enabled bool + Mode string + ActivationPolicy string + MaxInjected int + DefaultRefreshWindow int +} + +// GetEvolveConfig returns the evolution loop configuration with defaults applied. +func (s *EntireSettings) GetEvolveConfig() EvolveSettings { + if s.EvolveConfig == nil { + return EvolveSettings{SessionThreshold: 5} + } + cfg := *s.EvolveConfig + if cfg.SessionThreshold == 0 { + cfg.SessionThreshold = 5 + } + return cfg +} + +// GetMemoryLoopConfig returns memory-loop configuration with defaults applied. +func (s *EntireSettings) GetMemoryLoopConfig() MemoryLoopConfig { + cfg := MemoryLoopConfig{ + MaxInjected: 3, + DefaultRefreshWindow: 20, + Mode: memoryLoopModeOff, + ActivationPolicy: memoryLoopActivationPolicyReview, + } + if s.MemoryLoopConfig == nil { + return cfg + } + if s.MemoryLoopConfig.Mode != "" { + cfg.Enabled = true + cfg.Mode = s.MemoryLoopConfig.Mode + } else { + cfg.Enabled = s.MemoryLoopConfig.Enabled + } + if s.MemoryLoopConfig.ActivationPolicy != "" { + cfg.ActivationPolicy = s.MemoryLoopConfig.ActivationPolicy + } + if s.MemoryLoopConfig.Mode == "" && s.MemoryLoopConfig.ClaudeInjectionEnabled != nil { + switch { + case !cfg.Enabled: + cfg.Mode = memoryLoopModeOff + case *s.MemoryLoopConfig.ClaudeInjectionEnabled: + cfg.Mode = memoryLoopModeAuto + default: + cfg.Mode = memoryLoopModeManual + } + } + if s.MemoryLoopConfig.MaxInjected != 0 { + cfg.MaxInjected = s.MemoryLoopConfig.MaxInjected + } + if s.MemoryLoopConfig.DefaultRefreshWindow != 0 { + cfg.DefaultRefreshWindow = s.MemoryLoopConfig.DefaultRefreshWindow + } + if cfg.MaxInjected == 0 { + cfg.MaxInjected = 3 + } + if cfg.DefaultRefreshWindow == 0 { + cfg.DefaultRefreshWindow = 20 + } + return cfg +} + // GetCommitLinking returns the effective commit linking mode. // Returns the explicit value if set, otherwise defaults to "prompt" // to preserve existing user behavior. @@ -135,6 +237,10 @@ func Load(ctx context.Context) (*EntireSettings, error) { } } + if err := validateSettings(settings); err != nil { + return nil, err + } + return settings, nil } @@ -167,8 +273,8 @@ func loadFromFile(filePath string) (*EntireSettings, error) { } // Validate commit_linking if set - if settings.CommitLinking != "" && settings.CommitLinking != CommitLinkingAlways && settings.CommitLinking != CommitLinkingPrompt { - return nil, fmt.Errorf("invalid commit_linking value %q: must be %q or %q", settings.CommitLinking, CommitLinkingAlways, CommitLinkingPrompt) + if err := validateSettings(settings); err != nil { + return nil, err } return settings, nil @@ -288,6 +394,112 @@ func mergeJSON(settings *EntireSettings, data []byte) error { settings.ExternalAgents = ea } + // Override evolve if present + if evolveRaw, ok := raw["evolve"]; ok { + var ev EvolveSettings + if err := json.Unmarshal(evolveRaw, &ev); err != nil { + return fmt.Errorf("parsing evolve field: %w", err) + } + settings.EvolveConfig = &ev + } + + // Override memory_loop if present + if memoryLoopRaw, ok := raw["memory_loop"]; ok { + if settings.MemoryLoopConfig == nil { + settings.MemoryLoopConfig = &MemoryLoopSettings{} + } + if err := mergeMemoryLoopSettings(settings.MemoryLoopConfig, memoryLoopRaw); err != nil { + return fmt.Errorf("parsing memory_loop field: %w", err) + } + } + + return nil +} + +func validateSettings(settings *EntireSettings) error { + if settings.CommitLinking != "" && settings.CommitLinking != CommitLinkingAlways && settings.CommitLinking != CommitLinkingPrompt { + return fmt.Errorf("invalid commit_linking value %q: must be %q or %q", settings.CommitLinking, CommitLinkingAlways, CommitLinkingPrompt) + } + if settings.MemoryLoopConfig == nil { + return nil + } + if settings.MemoryLoopConfig.Mode != "" { + switch settings.MemoryLoopConfig.Mode { + case memoryLoopModeOff, memoryLoopModeManual, memoryLoopModeAuto: + default: + return fmt.Errorf( + "invalid memory_loop.mode value %q: must be %q, %q, or %q", + settings.MemoryLoopConfig.Mode, + memoryLoopModeOff, + memoryLoopModeManual, + memoryLoopModeAuto, + ) + } + } + if settings.MemoryLoopConfig.ActivationPolicy != "" { + switch settings.MemoryLoopConfig.ActivationPolicy { + case memoryLoopActivationPolicyReview, memoryLoopActivationPolicyAuto: + default: + return fmt.Errorf( + "invalid memory_loop.activation_policy value %q: must be %q or %q", + settings.MemoryLoopConfig.ActivationPolicy, + memoryLoopActivationPolicyReview, + memoryLoopActivationPolicyAuto, + ) + } + } + return nil +} + +func mergeMemoryLoopSettings(dst *MemoryLoopSettings, data json.RawMessage) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parsing memory_loop: %w", err) + } + + if enabledRaw, ok := raw["enabled"]; ok { + var enabled bool + if err := json.Unmarshal(enabledRaw, &enabled); err != nil { + return fmt.Errorf("parsing memory_loop.enabled: %w", err) + } + dst.Enabled = enabled + } + if modeRaw, ok := raw["mode"]; ok { + var mode string + if err := json.Unmarshal(modeRaw, &mode); err != nil { + return fmt.Errorf("parsing memory_loop.mode: %w", err) + } + dst.Mode = mode + } + if policyRaw, ok := raw["activation_policy"]; ok { + var policy string + if err := json.Unmarshal(policyRaw, &policy); err != nil { + return fmt.Errorf("parsing memory_loop.activation_policy: %w", err) + } + dst.ActivationPolicy = policy + } + if injectionRaw, ok := raw["claude_injection_enabled"]; ok { + var enabled bool + if err := json.Unmarshal(injectionRaw, &enabled); err != nil { + return fmt.Errorf("parsing memory_loop.claude_injection_enabled: %w", err) + } + dst.ClaudeInjectionEnabled = &enabled + } + if maxInjectedRaw, ok := raw["max_injected"]; ok { + var maxInjected int + if err := json.Unmarshal(maxInjectedRaw, &maxInjected); err != nil { + return fmt.Errorf("parsing memory_loop.max_injected: %w", err) + } + dst.MaxInjected = maxInjected + } + if refreshWindowRaw, ok := raw["default_refresh_window"]; ok { + var refreshWindow int + if err := json.Unmarshal(refreshWindowRaw, &refreshWindow); err != nil { + return fmt.Errorf("parsing memory_loop.default_refresh_window: %w", err) + } + dst.DefaultRefreshWindow = refreshWindow + } + return nil } diff --git a/cmd/entire/cli/settings/settings_evolve_test.go b/cmd/entire/cli/settings/settings_evolve_test.go new file mode 100644 index 000000000..6307e6c6e --- /dev/null +++ b/cmd/entire/cli/settings/settings_evolve_test.go @@ -0,0 +1,62 @@ +package settings + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetEvolveConfig_NilConfig_ReturnsDefaults(t *testing.T) { + t.Parallel() + + s := &EntireSettings{} + cfg := s.GetEvolveConfig() + + assert.False(t, cfg.Enabled) + assert.Equal(t, 5, cfg.SessionThreshold) +} + +func TestGetEvolveConfig_ZeroThreshold_DefaultsFiveToFive(t *testing.T) { + t.Parallel() + + s := &EntireSettings{ + EvolveConfig: &EvolveSettings{ + Enabled: true, + SessionThreshold: 0, + }, + } + cfg := s.GetEvolveConfig() + + assert.True(t, cfg.Enabled) + assert.Equal(t, 5, cfg.SessionThreshold) +} + +func TestGetEvolveConfig_ExplicitValues_Preserved(t *testing.T) { + t.Parallel() + + s := &EntireSettings{ + EvolveConfig: &EvolveSettings{ + Enabled: true, + SessionThreshold: 10, + }, + } + cfg := s.GetEvolveConfig() + + assert.True(t, cfg.Enabled) + assert.Equal(t, 10, cfg.SessionThreshold) +} + +func TestGetEvolveConfig_ExplicitDisabled_Preserved(t *testing.T) { + t.Parallel() + + s := &EntireSettings{ + EvolveConfig: &EvolveSettings{ + Enabled: false, + SessionThreshold: 3, + }, + } + cfg := s.GetEvolveConfig() + + assert.False(t, cfg.Enabled) + assert.Equal(t, 3, cfg.SessionThreshold) +} diff --git a/cmd/entire/cli/skill_cmd.go b/cmd/entire/cli/skill_cmd.go new file mode 100644 index 000000000..3e97339ab --- /dev/null +++ b/cmd/entire/cli/skill_cmd.go @@ -0,0 +1,41 @@ +package cli + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/skilltui" +) + +func newSkillCmd() *cobra.Command { + return &cobra.Command{ + Use: "skill", + Short: "Skill analytics and improvement dashboard", + Long: "Interactive TUI that discovers skill files, tracks their usage from session data, and generates AI-powered improvement suggestions.", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + if IsAccessibleMode() { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "The skill dashboard requires an interactive terminal. Set ACCESSIBLE= to disable accessible mode.") + return NewSilentError(errors.New("skill TUI requires interactive terminal")) + } + + worktreeRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Please run from within a git repository.") + return NewSilentError(fmt.Errorf("not a git repository: %w", err)) + } + + skillDBPath := filepath.Join(worktreeRoot, paths.EntireDir, "skill-analytics.db") + insightsDBPath := filepath.Join(worktreeRoot, paths.EntireDir, "insights.db") + + return skilltui.Run(ctx, skillDBPath, insightsDBPath, worktreeRoot) + }, + } +} diff --git a/cmd/entire/cli/skill_cmd_test.go b/cmd/entire/cli/skill_cmd_test.go new file mode 100644 index 000000000..2798b7075 --- /dev/null +++ b/cmd/entire/cli/skill_cmd_test.go @@ -0,0 +1,31 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewSkillCmd_Registers(t *testing.T) { + t.Parallel() + + root := NewRootCmd() + + var found bool + for _, cmd := range root.Commands() { + if cmd.Name() == "skill" { + found = true + break + } + } + assert.True(t, found, "expected 'skill' command to be registered") +} + +func TestSkillCmd_HasExpectedMetadata(t *testing.T) { + t.Parallel() + + cmd := newSkillCmd() + assert.Equal(t, "skill", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotNil(t, cmd.RunE) +} diff --git a/cmd/entire/cli/skilldb/cache.go b/cmd/entire/cli/skilldb/cache.go new file mode 100644 index 000000000..ba8b72fa8 --- /dev/null +++ b/cmd/entire/cli/skilldb/cache.go @@ -0,0 +1,219 @@ +package skilldb + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/insightsdb" +) + +// PopulateFromInsightsDB populates the skill analytics DB from insightsdb data. +// It queries skill_signals and tool_calls to find sessions that used discovered skills. +func (sdb *SkillDB) PopulateFromInsightsDB(ctx context.Context, idb *insightsdb.InsightsDB, discoveredSkills []SkillRow) error { + if len(discoveredSkills) == 0 { + return nil + } + + // Collect skill names for querying. + skillNames := make([]string, len(discoveredSkills)) + skillMap := make(map[string]SkillRow, len(discoveredSkills)) + for i, s := range discoveredSkills { + skillNames[i] = s.Name + skillMap[s.Name] = s + } + + tx, err := sdb.BeginTx(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() //nolint:errcheck // Rollback after failed tx; error is irrelevant + } + }() + + // Track which sessions we've already inserted to avoid duplicates. + type sessionKey struct { + skillName string + checkpointID string + sessionIndex int + } + inserted := make(map[sessionKey]bool) + + // Step 1: From skill_signals — sessions with friction for specific skills. + signals, err := idb.QuerySkillSignalsForSkills(ctx, skillNames) + if err != nil { + return fmt.Errorf("query skill signals: %w", err) + } + + for _, sig := range signals { + skill, ok := skillMap[sig.SkillName] + if !ok { + continue + } + + key := sessionKey{sig.SkillName, sig.CheckpointID, sig.SessionIndex} + if inserted[key] { + continue + } + inserted[key] = true + + frictionCount := len(sig.Friction) + outcome := "success" + if frictionCount > 0 { + outcome = "friction" + } + + if err = sdb.InsertSessionTx(ctx, tx, SkillSessionRow{ + SkillName: sig.SkillName, + SourceAgent: skill.SourceAgent, + CheckpointID: sig.CheckpointID, + SessionIndex: sig.SessionIndex, + SessionID: sig.SessionID, + Agent: sig.Agent, + Model: sig.Model, + Branch: sig.Branch, + CreatedAt: sig.CreatedAt, + TotalTokens: sig.TotalTokens, + TurnCount: sig.TurnCount, + OverallScore: sig.OverallScore, + FrictionCount: frictionCount, + Outcome: outcome, + }); err != nil { + return fmt.Errorf("insert skill session: %w", err) + } + + // Insert friction items. + for _, f := range sig.Friction { + if err = sdb.InsertFrictionTx(ctx, tx, + sig.SkillName, skill.SourceAgent, + sig.CheckpointID, sig.SessionIndex, + f, "", + ); err != nil { + return fmt.Errorf("insert skill friction: %w", err) + } + } + + // Insert missing instruction if present. + if sig.MissingInstruction != "" { + evidence := strings.Join(sig.Friction, "\n") + if err = sdb.InsertMissingInstructionTx(ctx, tx, + sig.SkillName, skill.SourceAgent, + sig.CheckpointID, sig.SessionIndex, + sig.MissingInstruction, evidence, + ); err != nil { + return fmt.Errorf("insert missing instruction: %w", err) + } + } + } + + // Step 2: From tool_calls — sessions that used the Skill tool (friction-free uses). + toolSessions, err := idb.QuerySkillToolCallSessions(ctx) + if err != nil { + return fmt.Errorf("query skill tool call sessions: %w", err) + } + + for _, ts := range toolSessions { + // We can't determine which specific skill was used from tool_calls alone, + // so we skip sessions already covered by skill_signals. + // These sessions indicate the Skill tool was invoked but we only record + // them if there's exactly one discovered skill (unambiguous attribution). + if len(discoveredSkills) == 1 { + skill := discoveredSkills[0] + key := sessionKey{skill.Name, ts.CheckpointID, ts.SessionIndex} + if inserted[key] { + continue + } + inserted[key] = true + + if err = sdb.InsertSessionTx(ctx, tx, SkillSessionRow{ + SkillName: skill.Name, + SourceAgent: skill.SourceAgent, + CheckpointID: ts.CheckpointID, + SessionIndex: ts.SessionIndex, + SessionID: ts.SessionID, + Agent: ts.Agent, + Model: ts.Model, + Branch: ts.Branch, + CreatedAt: ts.CreatedAt, + TotalTokens: ts.TotalTokens, + TurnCount: ts.TurnCount, + OverallScore: ts.OverallScore, + Outcome: "success", + }); err != nil { + return fmt.Errorf("insert tool call session: %w", err) + } + } + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +// RefreshFromInsightsDB checks if the cache is stale and repopulates if needed. +// Returns true if the cache was refreshed. +func (sdb *SkillDB) RefreshFromInsightsDB(ctx context.Context, idb *insightsdb.InsightsDB, discoveredSkills []SkillRow) (bool, error) { + // Check insightsdb branch tip. + currentTip, err := idb.GetBranchTip(ctx) + if err != nil { + return false, fmt.Errorf("get insightsdb branch tip: %w", err) + } + + cachedTip, err := sdb.GetCacheTip(ctx) + if err != nil { + return false, fmt.Errorf("get skilldb cache tip: %w", err) + } + + if currentTip != "" && currentTip == cachedTip { + return false, nil + } + + // Upsert discovered skills. + now := time.Now().UTC() + for _, skill := range discoveredSkills { + if err = sdb.UpsertSkill(ctx, SkillRow{ + Name: skill.Name, + SourceAgent: skill.SourceAgent, + Path: skill.Path, + Kind: skill.Kind, + DiscoveredAt: now, + LastSeenAt: now, + }); err != nil { + return false, fmt.Errorf("upsert skill %q: %w", skill.Name, err) + } + } + + // Clear existing session data before repopulating. + if err = sdb.clearSessionData(ctx); err != nil { + return false, fmt.Errorf("clear session data: %w", err) + } + + // Populate from insightsdb. + if err = sdb.PopulateFromInsightsDB(ctx, idb, discoveredSkills); err != nil { + return false, fmt.Errorf("populate from insightsdb: %w", err) + } + + // Update cache tip. + if currentTip != "" { + if err = sdb.SetCacheTip(ctx, currentTip); err != nil { + return false, fmt.Errorf("set cache tip: %w", err) + } + } + + return true, nil +} + +// clearSessionData removes all rows from session-related tables. +func (sdb *SkillDB) clearSessionData(ctx context.Context) error { + tables := []string{"skill_sessions", "skill_friction", "skill_missing_instructions"} + for _, table := range tables { + if _, err := sdb.db.ExecContext(ctx, "DELETE FROM "+table); err != nil { //nolint:gosec // table names are hardcoded + return fmt.Errorf("clear %s: %w", table, err) + } + } + return nil +} diff --git a/cmd/entire/cli/skilldb/db.go b/cmd/entire/cli/skilldb/db.go new file mode 100644 index 000000000..d68589c68 --- /dev/null +++ b/cmd/entire/cli/skilldb/db.go @@ -0,0 +1,163 @@ +// Package skilldb provides a SQLite database for skill analytics. +// It stores skill metadata, session performance data, friction themes, +// missing instructions, and improvement suggestions. +package skilldb + +import ( + "context" + "database/sql" + "fmt" + + _ "modernc.org/sqlite" // SQLite driver +) + +// SkillDB wraps a SQLite database for skill analytics. +type SkillDB struct { + db *sql.DB +} + +// Open opens (or creates) the skill analytics database at the given path. +// It sets WAL mode and busy timeout for safe concurrent access, +// then runs migrations to ensure all tables exist. +func Open(dbPath string) (*SkillDB, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open skill analytics database: %w", err) + } + + if err = applyPragmas(db); err != nil { + _ = db.Close() + return nil, err + } + + sdb := &SkillDB{db: db} + if err = sdb.migrate(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("run migrations: %w", err) + } + + return sdb, nil +} + +// applyPragmas sets performance and safety pragmas on the database. +func applyPragmas(db *sql.DB) error { + ctx := context.Background() + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA busy_timeout=5000", + } + for _, pragma := range pragmas { + if _, err := db.ExecContext(ctx, pragma); err != nil { + return fmt.Errorf("apply pragma %q: %w", pragma, err) + } + } + return nil +} + +// Close closes the underlying database connection. +func (sdb *SkillDB) Close() error { + if err := sdb.db.Close(); err != nil { + return fmt.Errorf("close skill analytics database: %w", err) + } + return nil +} + +// migrate creates all tables if they do not already exist. +// It is safe to call multiple times (idempotent). +func (sdb *SkillDB) migrate() error { + ctx := context.Background() + statements := []string{ + `CREATE TABLE IF NOT EXISTS skills ( + name TEXT NOT NULL, + source_agent TEXT NOT NULL, + path TEXT NOT NULL, + kind TEXT NOT NULL, + discovered_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + PRIMARY KEY (name, source_agent) + )`, + `CREATE TABLE IF NOT EXISTS skill_sessions ( + skill_name TEXT NOT NULL, + source_agent TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + session_id TEXT, + agent TEXT, + model TEXT, + branch TEXT, + created_at TEXT NOT NULL, + total_tokens INTEGER DEFAULT 0, + turn_count INTEGER DEFAULT 0, + overall_score REAL, + friction_count INTEGER DEFAULT 0, + outcome TEXT, + PRIMARY KEY (skill_name, source_agent, checkpoint_id, session_index) + )`, + `CREATE INDEX IF NOT EXISTS idx_skill_sessions_created ON skill_sessions(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_skill_sessions_agent ON skill_sessions(agent)`, + `CREATE TABLE IF NOT EXISTS skill_friction ( + skill_name TEXT NOT NULL, + source_agent TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + text TEXT NOT NULL, + category TEXT + )`, + `CREATE TABLE IF NOT EXISTS skill_missing_instructions ( + skill_name TEXT NOT NULL, + source_agent TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + session_index INTEGER NOT NULL, + instruction TEXT NOT NULL, + evidence TEXT + )`, + `CREATE TABLE IF NOT EXISTS skill_improvements ( + id TEXT PRIMARY KEY, + skill_name TEXT NOT NULL, + source_agent TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + diff TEXT, + priority TEXT DEFAULT 'medium', + status TEXT DEFAULT 'pending', + created_at TEXT NOT NULL, + applied_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS cache_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + } + + for _, stmt := range statements { + if _, err := sdb.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("execute migration statement: %w", err) + } + } + return nil +} + +// ListTables returns the names of all user tables in the database. +// This is used in tests to verify migrations ran correctly. +func (sdb *SkillDB) ListTables(ctx context.Context) ([]string, error) { + rows, err := sdb.db.QueryContext(ctx, + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", + ) + if err != nil { + return nil, fmt.Errorf("query tables: %w", err) + } + defer rows.Close() + + var tables []string + for rows.Next() { + var name string + if err = rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan table name: %w", err) + } + tables = append(tables, name) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate tables: %w", err) + } + return tables, nil +} diff --git a/cmd/entire/cli/skilldb/db_test.go b/cmd/entire/cli/skilldb/db_test.go new file mode 100644 index 000000000..aba958508 --- /dev/null +++ b/cmd/entire/cli/skilldb/db_test.go @@ -0,0 +1,83 @@ +package skilldb_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/skilldb" +) + +func TestOpen_CreatesDatabase(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "skills.db") + + db, err := skilldb.Open(dbPath) + require.NoError(t, err) + require.NotNil(t, db) + + t.Cleanup(func() { + assert.NoError(t, db.Close()) + }) +} + +func TestOpen_CreatesAllTables(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "skills.db") + + db, err := skilldb.Open(dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + ctx := context.Background() + + tables, err := db.ListTables(ctx) + require.NoError(t, err) + + expected := []string{ + "cache_meta", + "skill_friction", + "skill_improvements", + "skill_missing_instructions", + "skill_sessions", + "skills", + } + for _, table := range expected { + assert.Contains(t, tables, table, "expected table %q to exist", table) + } +} + +func TestOpen_Idempotent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "skills.db") + + db1, err := skilldb.Open(dbPath) + require.NoError(t, err) + require.NoError(t, db1.Close()) + + db2, err := skilldb.Open(dbPath) + require.NoError(t, err) + require.NoError(t, db2.Close()) +} + +func TestClose_CanBeCalledOnce(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "skills.db") + + db, err := skilldb.Open(dbPath) + require.NoError(t, err) + + err = db.Close() + assert.NoError(t, err) +} diff --git a/cmd/entire/cli/skilldb/discovery.go b/cmd/entire/cli/skilldb/discovery.go new file mode 100644 index 000000000..8ab4cd710 --- /dev/null +++ b/cmd/entire/cli/skilldb/discovery.go @@ -0,0 +1,121 @@ +package skilldb + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// DiscoveredSkill represents a skill file found in an agent's config directory. +type DiscoveredSkill struct { + Name string // skill name (e.g., "e2e", "dev") + SourceAgent string // "claude-code" or "gemini-cli" + Path string // relative path from repo root (e.g., ".claude/skills/e2e/SKILL.md") + Kind string // "skill", "command", or "agent-def" +} + +// DiscoverSkills scans repoRoot for skill files across agent config directories. +// Missing directories are silently skipped. +func DiscoverSkills(repoRoot string) ([]DiscoveredSkill, error) { + var skills []DiscoveredSkill + + collectors := []struct { + pattern string + sourceAgent string + kind string + nameFunc func(match string) string + readContent bool + }{ + { + pattern: filepath.Join(repoRoot, ".claude", "skills", "*", "SKILL.md"), + sourceAgent: "claude-code", + kind: "skill", + nameFunc: func(match string) string { return filepath.Base(filepath.Dir(match)) }, + }, + { + pattern: filepath.Join(repoRoot, ".claude", "commands", "*.md"), + sourceAgent: "claude-code", + kind: "command", + nameFunc: func(match string) string { return strings.TrimSuffix(filepath.Base(match), ".md") }, + }, + { + pattern: filepath.Join(repoRoot, ".gemini", "agents", "*.md"), + sourceAgent: "gemini-cli", + kind: "agent-def", + readContent: true, + nameFunc: func(match string) string { return strings.TrimSuffix(filepath.Base(match), ".md") }, + }, + { + pattern: filepath.Join(repoRoot, ".gemini", "commands", "*.md"), + sourceAgent: "gemini-cli", + kind: "command", + readContent: true, + nameFunc: func(match string) string { return strings.TrimSuffix(filepath.Base(match), ".md") }, + }, + } + + for _, c := range collectors { + matches, err := filepath.Glob(c.pattern) + if err != nil { + return nil, fmt.Errorf("globbing %s: %w", c.pattern, err) + } + + for _, match := range matches { + name := c.nameFunc(match) + + if c.readContent { + content, err := os.ReadFile(match) //nolint:gosec // match comes from filepath.Glob, not user input + if err != nil { + return nil, fmt.Errorf("reading %s: %w", match, err) + } + if yamlName := extractYAMLName(string(content)); yamlName != "" { + name = yamlName + } + } + + relPath, err := filepath.Rel(repoRoot, match) + if err != nil { + return nil, fmt.Errorf("computing relative path for %s: %w", match, err) + } + + skills = append(skills, DiscoveredSkill{ + Name: name, + SourceAgent: c.sourceAgent, + Path: relPath, + Kind: c.kind, + }) + } + } + + sort.Slice(skills, func(i, j int) bool { + if skills[i].SourceAgent != skills[j].SourceAgent { + return skills[i].SourceAgent < skills[j].SourceAgent + } + return skills[i].Name < skills[j].Name + }) + + return skills, nil +} + +// extractYAMLName looks for a name field in YAML frontmatter delimited by "---". +func extractYAMLName(content string) string { + lines := strings.Split(content, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return "" + } + + for _, line := range lines[1:] { + trimmed := strings.TrimSpace(line) + if trimmed == "---" { + break + } + if strings.HasPrefix(trimmed, "name:") { + value := strings.TrimPrefix(trimmed, "name:") + return strings.TrimSpace(value) + } + } + + return "" +} diff --git a/cmd/entire/cli/skilldb/discovery_test.go b/cmd/entire/cli/skilldb/discovery_test.go new file mode 100644 index 000000000..8befbd835 --- /dev/null +++ b/cmd/entire/cli/skilldb/discovery_test.go @@ -0,0 +1,190 @@ +package skilldb_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/skilldb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTestFile(t *testing.T, root, relPath, content string) { + t.Helper() + full := filepath.Join(root, relPath) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) +} + +func TestDiscoverSkills_ClaudeSkills(t *testing.T) { + t.Parallel() + root := t.TempDir() + + writeTestFile(t, root, ".claude/skills/e2e/SKILL.md", "# E2E Skill\nRun e2e tests.") + writeTestFile(t, root, ".claude/skills/test-repo/SKILL.md", "# Test Repo\nManage test repos.") + + skills, err := skilldb.DiscoverSkills(root) + require.NoError(t, err) + require.Len(t, skills, 2) + + assert.Equal(t, "e2e", skills[0].Name) + assert.Equal(t, "claude-code", skills[0].SourceAgent) + assert.Equal(t, "skill", skills[0].Kind) + assert.Equal(t, filepath.Join(".claude", "skills", "e2e", "SKILL.md"), skills[0].Path) + + assert.Equal(t, "test-repo", skills[1].Name) + assert.Equal(t, "claude-code", skills[1].SourceAgent) + assert.Equal(t, "skill", skills[1].Kind) + assert.Equal(t, filepath.Join(".claude", "skills", "test-repo", "SKILL.md"), skills[1].Path) +} + +func TestDiscoverSkills_ClaudeCommands(t *testing.T) { + t.Parallel() + root := t.TempDir() + + writeTestFile(t, root, ".claude/commands/dev.md", "Development command") + writeTestFile(t, root, ".claude/commands/reviewer.md", "Review command") + + skills, err := skilldb.DiscoverSkills(root) + require.NoError(t, err) + require.Len(t, skills, 2) + + assert.Equal(t, "dev", skills[0].Name) + assert.Equal(t, "claude-code", skills[0].SourceAgent) + assert.Equal(t, "command", skills[0].Kind) + assert.Equal(t, filepath.Join(".claude", "commands", "dev.md"), skills[0].Path) + + assert.Equal(t, "reviewer", skills[1].Name) + assert.Equal(t, "claude-code", skills[1].SourceAgent) + assert.Equal(t, "command", skills[1].Kind) + assert.Equal(t, filepath.Join(".claude", "commands", "reviewer.md"), skills[1].Path) +} + +func TestDiscoverSkills_GeminiAgents(t *testing.T) { + t.Parallel() + root := t.TempDir() + + writeTestFile(t, root, ".gemini/agents/dev.md", "---\nname: developer\n---\nA developer agent.") + + skills, err := skilldb.DiscoverSkills(root) + require.NoError(t, err) + require.Len(t, skills, 1) + + assert.Equal(t, "developer", skills[0].Name) + assert.Equal(t, "gemini-cli", skills[0].SourceAgent) + assert.Equal(t, "agent-def", skills[0].Kind) + assert.Equal(t, filepath.Join(".gemini", "agents", "dev.md"), skills[0].Path) +} + +func TestDiscoverSkills_GeminiCommands(t *testing.T) { + t.Parallel() + root := t.TempDir() + + writeTestFile(t, root, ".gemini/commands/test.md", "A test command without frontmatter.") + + skills, err := skilldb.DiscoverSkills(root) + require.NoError(t, err) + require.Len(t, skills, 1) + + assert.Equal(t, "test", skills[0].Name) + assert.Equal(t, "gemini-cli", skills[0].SourceAgent) + assert.Equal(t, "command", skills[0].Kind) + assert.Equal(t, filepath.Join(".gemini", "commands", "test.md"), skills[0].Path) +} + +func TestDiscoverSkills_AllPatterns(t *testing.T) { + t.Parallel() + root := t.TempDir() + + writeTestFile(t, root, ".claude/skills/e2e/SKILL.md", "# E2E") + writeTestFile(t, root, ".claude/commands/dev.md", "Dev command") + writeTestFile(t, root, ".gemini/agents/coder.md", "---\nname: coder-agent\n---\nCodes things.") + writeTestFile(t, root, ".gemini/commands/build.md", "Build command") + + skills, err := skilldb.DiscoverSkills(root) + require.NoError(t, err) + require.Len(t, skills, 4) + + // Sorted by source_agent then name: claude-code first, then gemini-cli + assert.Equal(t, "claude-code", skills[0].SourceAgent) + assert.Equal(t, "dev", skills[0].Name) + assert.Equal(t, "command", skills[0].Kind) + + assert.Equal(t, "claude-code", skills[1].SourceAgent) + assert.Equal(t, "e2e", skills[1].Name) + assert.Equal(t, "skill", skills[1].Kind) + + assert.Equal(t, "gemini-cli", skills[2].SourceAgent) + assert.Equal(t, "build", skills[2].Name) + assert.Equal(t, "command", skills[2].Kind) + + assert.Equal(t, "gemini-cli", skills[3].SourceAgent) + assert.Equal(t, "coder-agent", skills[3].Name) + assert.Equal(t, "agent-def", skills[3].Kind) +} + +func TestDiscoverSkills_EmptyRepo(t *testing.T) { + t.Parallel() + root := t.TempDir() + + skills, err := skilldb.DiscoverSkills(root) + require.NoError(t, err) + assert.Empty(t, skills) +} + +func TestDiscoverSkills_YAMLNameExtraction(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantName string + }{ + { + name: "valid frontmatter with name", + content: "---\nname: my-skill\n---\nBody text.", + wantName: "my-skill", + }, + { + name: "frontmatter with extra fields", + content: "---\ndescription: some desc\nname: extracted\nversion: 1\n---\nBody.", + wantName: "extracted", + }, + { + name: "no frontmatter", + content: "Just plain markdown.", + wantName: "fallback", + }, + { + name: "empty frontmatter", + content: "---\n---\nBody.", + wantName: "fallback", + }, + { + name: "frontmatter without name field", + content: "---\ndescription: hello\n---\nBody.", + wantName: "fallback", + }, + { + name: "name with spaces", + content: "---\nname: spaced name \n---\n", + wantName: "spaced name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + + writeTestFile(t, root, ".gemini/agents/fallback.md", tt.content) + + skills, err := skilldb.DiscoverSkills(root) + require.NoError(t, err) + require.Len(t, skills, 1) + + assert.Equal(t, tt.wantName, skills[0].Name) + }) + } +} diff --git a/cmd/entire/cli/skilldb/queries.go b/cmd/entire/cli/skilldb/queries.go new file mode 100644 index 000000000..3d00899c3 --- /dev/null +++ b/cmd/entire/cli/skilldb/queries.go @@ -0,0 +1,619 @@ +package skilldb + +import ( + "context" + "database/sql" + "fmt" + "math" + "strings" + "time" +) + +// SkillRow represents a skill definition stored in the skills table. +type SkillRow struct { + Name string + SourceAgent string + Path string + Kind string + DiscoveredAt time.Time + LastSeenAt time.Time +} + +// SkillStatsResult contains aggregated statistics for a skill. +type SkillStatsResult struct { + TotalSessions int + AvgScore float64 + TotalFriction int + TotalTokens int64 + FirstUsed time.Time + LastUsed time.Time + SessionsPerWeek float64 +} + +// SkillSessionRow represents a single session for a skill. +type SkillSessionRow struct { + SkillName string + SourceAgent string + CheckpointID string + SessionIndex int + SessionID string + Agent string + Model string + Branch string + CreatedAt time.Time + TotalTokens int + TurnCount int + OverallScore float64 + FrictionCount int + Outcome string +} + +// FrictionThemeRow groups recurring friction entries by their text content. +type FrictionThemeRow struct { + Text string + Category string + Count int + Sessions []string +} + +// MissingInstructionRow groups recurring missing instructions. +type MissingInstructionRow struct { + Instruction string + Count int + Evidence []string + Sessions []string +} + +// AgentBreakdownRow contains per-agent aggregated statistics. +type AgentBreakdownRow struct { + Agent string + SessionCount int + AvgScore float64 + TotalTokens int64 +} + +// SkillImprovement represents a suggested improvement for a skill. +type SkillImprovement struct { + ID string + SkillName string + SourceAgent string + Title string + Description string + Diff string + Priority string + Status string + CreatedAt time.Time + AppliedAt *time.Time +} + +// UpsertSkill inserts or updates a skill, preserving discovered_at on update. +func (sdb *SkillDB) UpsertSkill(ctx context.Context, skill SkillRow) error { + _, err := sdb.db.ExecContext(ctx, ` + INSERT INTO skills (name, source_agent, path, kind, discovered_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(name, source_agent) DO UPDATE SET + path = excluded.path, + kind = excluded.kind, + last_seen_at = excluded.last_seen_at`, + skill.Name, skill.SourceAgent, skill.Path, skill.Kind, + skill.DiscoveredAt.UTC().Format(time.RFC3339), + skill.LastSeenAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("upsert skill: %w", err) + } + return nil +} + +// ListSkills returns all skills from the skills table. +func (sdb *SkillDB) ListSkills(ctx context.Context) ([]SkillRow, error) { + rows, err := sdb.db.QueryContext(ctx, + "SELECT name, source_agent, path, kind, discovered_at, last_seen_at FROM skills ORDER BY name", + ) + if err != nil { + return nil, fmt.Errorf("list skills: %w", err) + } + defer rows.Close() + + var skills []SkillRow + for rows.Next() { + var s SkillRow + var discoveredAt, lastSeenAt string + if err = rows.Scan(&s.Name, &s.SourceAgent, &s.Path, &s.Kind, &discoveredAt, &lastSeenAt); err != nil { + return nil, fmt.Errorf("scan skill: %w", err) + } + s.DiscoveredAt, err = time.Parse(time.RFC3339, discoveredAt) + if err != nil { + return nil, fmt.Errorf("parse discovered_at %q: %w", discoveredAt, err) + } + s.LastSeenAt, err = time.Parse(time.RFC3339, lastSeenAt) + if err != nil { + return nil, fmt.Errorf("parse last_seen_at %q: %w", lastSeenAt, err) + } + skills = append(skills, s) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate skills: %w", err) + } + return skills, nil +} + +// SkillStats returns aggregated statistics for a specific skill. +func (sdb *SkillDB) SkillStats(ctx context.Context, name, sourceAgent string) (*SkillStatsResult, error) { + var result SkillStatsResult + var avgScore sql.NullFloat64 + var firstUsed, lastUsed sql.NullString + + err := sdb.db.QueryRowContext(ctx, ` + SELECT + COUNT(*) AS total_sessions, + COALESCE(AVG(overall_score), 0) AS avg_score, + COALESCE(SUM(friction_count), 0) AS total_friction, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + MIN(created_at) AS first_used, + MAX(created_at) AS last_used + FROM skill_sessions + WHERE skill_name = ? AND source_agent = ?`, + name, sourceAgent, + ).Scan( + &result.TotalSessions, + &avgScore, + &result.TotalFriction, + &result.TotalTokens, + &firstUsed, + &lastUsed, + ) + if err != nil { + return nil, fmt.Errorf("skill stats: %w", err) + } + + result.AvgScore = avgScore.Float64 + + if firstUsed.Valid { + result.FirstUsed, err = time.Parse(time.RFC3339, firstUsed.String) + if err != nil { + return nil, fmt.Errorf("parse first_used %q: %w", firstUsed.String, err) + } + } + if lastUsed.Valid { + result.LastUsed, err = time.Parse(time.RFC3339, lastUsed.String) + if err != nil { + return nil, fmt.Errorf("parse last_used %q: %w", lastUsed.String, err) + } + } + + if result.TotalSessions > 0 && !result.FirstUsed.IsZero() { + weeks := time.Since(result.FirstUsed).Hours() / (24 * 7) + if weeks < 1 { + weeks = 1 + } + result.SessionsPerWeek = math.Round(float64(result.TotalSessions)/weeks*100) / 100 + } + + return &result, nil +} + +// RecentSessions returns the last N sessions for a skill, ordered by created_at DESC. +func (sdb *SkillDB) RecentSessions(ctx context.Context, name, sourceAgent string, limit int) ([]SkillSessionRow, error) { + rows, err := sdb.db.QueryContext(ctx, ` + SELECT skill_name, source_agent, checkpoint_id, session_index, + session_id, agent, model, branch, created_at, + total_tokens, turn_count, overall_score, friction_count, outcome + FROM skill_sessions + WHERE skill_name = ? AND source_agent = ? + ORDER BY created_at DESC + LIMIT ?`, + name, sourceAgent, limit, + ) + if err != nil { + return nil, fmt.Errorf("recent sessions: %w", err) + } + defer rows.Close() + + var sessions []SkillSessionRow + for rows.Next() { + s, scanErr := scanSkillSession(rows) + if scanErr != nil { + return nil, scanErr + } + sessions = append(sessions, s) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate recent sessions: %w", err) + } + return sessions, nil +} + +// SkillFrictionThemes returns friction entries grouped by text for a skill. +func (sdb *SkillDB) SkillFrictionThemes(ctx context.Context, name, sourceAgent string) ([]FrictionThemeRow, error) { + rows, err := sdb.db.QueryContext(ctx, ` + SELECT text, category, COUNT(*) AS cnt, GROUP_CONCAT(DISTINCT checkpoint_id) AS sessions + FROM skill_friction + WHERE skill_name = ? AND source_agent = ? + GROUP BY text, category + ORDER BY cnt DESC`, + name, sourceAgent, + ) + if err != nil { + return nil, fmt.Errorf("skill friction themes: %w", err) + } + defer rows.Close() + + var themes []FrictionThemeRow + for rows.Next() { + var t FrictionThemeRow + var category sql.NullString + var sessionsCSV string + if err = rows.Scan(&t.Text, &category, &t.Count, &sessionsCSV); err != nil { + return nil, fmt.Errorf("scan friction theme: %w", err) + } + t.Category = category.String + t.Sessions = splitCSV(sessionsCSV) + themes = append(themes, t) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate friction themes: %w", err) + } + return themes, nil +} + +// SkillMissingInstructions returns missing instructions grouped by instruction for a skill. +func (sdb *SkillDB) SkillMissingInstructions(ctx context.Context, name, sourceAgent string) ([]MissingInstructionRow, error) { + rows, err := sdb.db.QueryContext(ctx, ` + SELECT instruction, COUNT(*) AS cnt, + GROUP_CONCAT(DISTINCT evidence) AS evidence, + GROUP_CONCAT(DISTINCT checkpoint_id) AS sessions + FROM skill_missing_instructions + WHERE skill_name = ? AND source_agent = ? + GROUP BY instruction + ORDER BY cnt DESC`, + name, sourceAgent, + ) + if err != nil { + return nil, fmt.Errorf("skill missing instructions: %w", err) + } + defer rows.Close() + + var instructions []MissingInstructionRow + for rows.Next() { + var m MissingInstructionRow + var evidenceConcat sql.NullString + var sessionsCSV string + if err = rows.Scan(&m.Instruction, &m.Count, &evidenceConcat, &sessionsCSV); err != nil { + return nil, fmt.Errorf("scan missing instruction: %w", err) + } + m.Evidence = splitCSV(evidenceConcat.String) + m.Sessions = splitCSV(sessionsCSV) + instructions = append(instructions, m) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate missing instructions: %w", err) + } + return instructions, nil +} + +// AgentBreakdown returns per-agent aggregated statistics for a skill. +func (sdb *SkillDB) AgentBreakdown(ctx context.Context, name, sourceAgent string) ([]AgentBreakdownRow, error) { + rows, err := sdb.db.QueryContext(ctx, ` + SELECT agent, COUNT(*) AS session_count, + COALESCE(AVG(overall_score), 0) AS avg_score, + COALESCE(SUM(total_tokens), 0) AS total_tokens + FROM skill_sessions + WHERE skill_name = ? AND source_agent = ? + GROUP BY agent + ORDER BY session_count DESC`, + name, sourceAgent, + ) + if err != nil { + return nil, fmt.Errorf("agent breakdown: %w", err) + } + defer rows.Close() + + var breakdown []AgentBreakdownRow + for rows.Next() { + var a AgentBreakdownRow + var agent sql.NullString + if err = rows.Scan(&agent, &a.SessionCount, &a.AvgScore, &a.TotalTokens); err != nil { + return nil, fmt.Errorf("scan agent breakdown: %w", err) + } + a.Agent = agent.String + breakdown = append(breakdown, a) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate agent breakdown: %w", err) + } + return breakdown, nil +} + +// InsertImprovement inserts a new improvement suggestion. +func (sdb *SkillDB) InsertImprovement(ctx context.Context, imp SkillImprovement) error { + _, err := sdb.db.ExecContext(ctx, ` + INSERT INTO skill_improvements (id, skill_name, source_agent, title, description, diff, priority, status, created_at, applied_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + imp.ID, imp.SkillName, imp.SourceAgent, imp.Title, + nullableString(imp.Description), nullableString(imp.Diff), + imp.Priority, imp.Status, + imp.CreatedAt.UTC().Format(time.RFC3339), + formatOptionalTime(imp.AppliedAt), + ) + if err != nil { + return fmt.Errorf("insert improvement: %w", err) + } + return nil +} + +// ListImprovements returns improvements for a skill, optionally filtered by status. +// If status is empty, all improvements are returned. +func (sdb *SkillDB) ListImprovements(ctx context.Context, name, sourceAgent, status string) ([]SkillImprovement, error) { + var query string + var args []interface{} + + if status != "" { + query = `SELECT id, skill_name, source_agent, title, description, diff, priority, status, created_at, applied_at + FROM skill_improvements + WHERE skill_name = ? AND source_agent = ? AND status = ? + ORDER BY created_at DESC` + args = []interface{}{name, sourceAgent, status} + } else { + query = `SELECT id, skill_name, source_agent, title, description, diff, priority, status, created_at, applied_at + FROM skill_improvements + WHERE skill_name = ? AND source_agent = ? + ORDER BY created_at DESC` + args = []interface{}{name, sourceAgent} + } + + rows, err := sdb.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list improvements: %w", err) + } + defer rows.Close() + + var improvements []SkillImprovement + for rows.Next() { + imp, scanErr := scanImprovement(rows) + if scanErr != nil { + return nil, scanErr + } + improvements = append(improvements, imp) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate improvements: %w", err) + } + return improvements, nil +} + +// UpdateImprovementStatus updates the status of an improvement by ID. +func (sdb *SkillDB) UpdateImprovementStatus(ctx context.Context, id, status string) error { + var appliedAt interface{} + if status == "applied" { + appliedAt = time.Now().UTC().Format(time.RFC3339) + } + + _, err := sdb.db.ExecContext(ctx, ` + UPDATE skill_improvements SET status = ?, applied_at = COALESCE(?, applied_at) + WHERE id = ?`, + status, appliedAt, id, + ) + if err != nil { + return fmt.Errorf("update improvement status: %w", err) + } + return nil +} + +// GetCacheTip returns the stored cache tip hash from cache_meta, +// or an empty string if it has not been set yet. +func (sdb *SkillDB) GetCacheTip(ctx context.Context) (string, error) { + var tip string + err := sdb.db.QueryRowContext(ctx, + "SELECT value FROM cache_meta WHERE key = ?", + "cache_tip", + ).Scan(&tip) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get cache tip: %w", err) + } + return tip, nil +} + +// SetCacheTip stores the cache tip hash in cache_meta. +// Overwrites any previously stored value. +func (sdb *SkillDB) SetCacheTip(ctx context.Context, tip string) error { + _, err := sdb.db.ExecContext(ctx, + "INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)", + "cache_tip", + tip, + ) + if err != nil { + return fmt.Errorf("set cache tip: %w", err) + } + return nil +} + +// InsertSession inserts a skill session row. +func (sdb *SkillDB) InsertSession(ctx context.Context, row SkillSessionRow) error { + _, err := sdb.db.ExecContext(ctx, ` + INSERT INTO skill_sessions (skill_name, source_agent, checkpoint_id, session_index, + session_id, agent, model, branch, created_at, + total_tokens, turn_count, overall_score, friction_count, outcome) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.SkillName, row.SourceAgent, row.CheckpointID, row.SessionIndex, + nullableString(row.SessionID), nullableString(row.Agent), + nullableString(row.Model), nullableString(row.Branch), + row.CreatedAt.UTC().Format(time.RFC3339), + row.TotalTokens, row.TurnCount, row.OverallScore, row.FrictionCount, + nullableString(row.Outcome), + ) + if err != nil { + return fmt.Errorf("insert skill session: %w", err) + } + return nil +} + +// InsertFriction inserts a friction entry for a skill session. +func (sdb *SkillDB) InsertFriction(ctx context.Context, skillName, sourceAgent, checkpointID string, sessionIndex int, text, category string) error { + _, err := sdb.db.ExecContext(ctx, + `INSERT INTO skill_friction (skill_name, source_agent, checkpoint_id, session_index, text, category) + VALUES (?, ?, ?, ?, ?, ?)`, + skillName, sourceAgent, checkpointID, sessionIndex, text, nullableString(category), + ) + if err != nil { + return fmt.Errorf("insert skill friction: %w", err) + } + return nil +} + +// InsertMissingInstruction inserts a missing instruction entry for a skill session. +func (sdb *SkillDB) InsertMissingInstruction(ctx context.Context, skillName, sourceAgent, checkpointID string, sessionIndex int, instruction, evidence string) error { + _, err := sdb.db.ExecContext(ctx, + `INSERT INTO skill_missing_instructions (skill_name, source_agent, checkpoint_id, session_index, instruction, evidence) + VALUES (?, ?, ?, ?, ?, ?)`, + skillName, sourceAgent, checkpointID, sessionIndex, instruction, nullableString(evidence), + ) + if err != nil { + return fmt.Errorf("insert skill missing instruction: %w", err) + } + return nil +} + +// BeginTx starts a new transaction for bulk inserts. +func (sdb *SkillDB) BeginTx(ctx context.Context) (*sql.Tx, error) { + tx, err := sdb.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + return tx, nil +} + +// InsertSessionTx inserts a skill session row within an existing transaction. +func (sdb *SkillDB) InsertSessionTx(ctx context.Context, tx *sql.Tx, row SkillSessionRow) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO skill_sessions (skill_name, source_agent, checkpoint_id, session_index, + session_id, agent, model, branch, created_at, + total_tokens, turn_count, overall_score, friction_count, outcome) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.SkillName, row.SourceAgent, row.CheckpointID, row.SessionIndex, + nullableString(row.SessionID), nullableString(row.Agent), + nullableString(row.Model), nullableString(row.Branch), + row.CreatedAt.UTC().Format(time.RFC3339), + row.TotalTokens, row.TurnCount, row.OverallScore, row.FrictionCount, + nullableString(row.Outcome), + ) + if err != nil { + return fmt.Errorf("insert skill session (tx): %w", err) + } + return nil +} + +// InsertFrictionTx inserts a friction entry within an existing transaction. +func (sdb *SkillDB) InsertFrictionTx(ctx context.Context, tx *sql.Tx, skillName, sourceAgent, checkpointID string, sessionIndex int, text, category string) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO skill_friction (skill_name, source_agent, checkpoint_id, session_index, text, category) + VALUES (?, ?, ?, ?, ?, ?)`, + skillName, sourceAgent, checkpointID, sessionIndex, text, nullableString(category), + ) + if err != nil { + return fmt.Errorf("insert skill friction (tx): %w", err) + } + return nil +} + +// InsertMissingInstructionTx inserts a missing instruction entry within an existing transaction. +func (sdb *SkillDB) InsertMissingInstructionTx(ctx context.Context, tx *sql.Tx, skillName, sourceAgent, checkpointID string, sessionIndex int, instruction, evidence string) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO skill_missing_instructions (skill_name, source_agent, checkpoint_id, session_index, instruction, evidence) + VALUES (?, ?, ?, ?, ?, ?)`, + skillName, sourceAgent, checkpointID, sessionIndex, instruction, nullableString(evidence), + ) + if err != nil { + return fmt.Errorf("insert skill missing instruction (tx): %w", err) + } + return nil +} + +// scanSkillSession reads one row from the skill_sessions table. +func scanSkillSession(rows *sql.Rows) (SkillSessionRow, error) { + var s SkillSessionRow + var sessionID, agent, model, branch, outcome sql.NullString + var overallScore sql.NullFloat64 + var createdAt string + + err := rows.Scan( + &s.SkillName, &s.SourceAgent, &s.CheckpointID, &s.SessionIndex, + &sessionID, &agent, &model, &branch, &createdAt, + &s.TotalTokens, &s.TurnCount, &overallScore, &s.FrictionCount, &outcome, + ) + if err != nil { + return s, fmt.Errorf("scan skill session: %w", err) + } + + s.SessionID = sessionID.String + s.Agent = agent.String + s.Model = model.String + s.Branch = branch.String + s.Outcome = outcome.String + s.OverallScore = overallScore.Float64 + + s.CreatedAt, err = time.Parse(time.RFC3339, createdAt) + if err != nil { + return s, fmt.Errorf("parse created_at %q: %w", createdAt, err) + } + return s, nil +} + +// scanImprovement reads one row from the skill_improvements table. +func scanImprovement(rows *sql.Rows) (SkillImprovement, error) { + var imp SkillImprovement + var description, diff, appliedAt sql.NullString + var createdAt string + + err := rows.Scan( + &imp.ID, &imp.SkillName, &imp.SourceAgent, &imp.Title, + &description, &diff, &imp.Priority, &imp.Status, + &createdAt, &appliedAt, + ) + if err != nil { + return imp, fmt.Errorf("scan improvement: %w", err) + } + + imp.Description = description.String + imp.Diff = diff.String + + imp.CreatedAt, err = time.Parse(time.RFC3339, createdAt) + if err != nil { + return imp, fmt.Errorf("parse created_at %q: %w", createdAt, err) + } + + if appliedAt.Valid { + t, parseErr := time.Parse(time.RFC3339, appliedAt.String) + if parseErr != nil { + return imp, fmt.Errorf("parse applied_at %q: %w", appliedAt.String, parseErr) + } + imp.AppliedAt = &t + } + + return imp, nil +} + +// nullableString converts an empty string to a SQL NULL value. +func nullableString(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func formatOptionalTime(t *time.Time) interface{} { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339) +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, ",") +} diff --git a/cmd/entire/cli/skilldb/queries_test.go b/cmd/entire/cli/skilldb/queries_test.go new file mode 100644 index 000000000..3fdb9adf8 --- /dev/null +++ b/cmd/entire/cli/skilldb/queries_test.go @@ -0,0 +1,336 @@ +package skilldb_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/skilldb" +) + +func openTestDB(t *testing.T) *skilldb.SkillDB { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "skills.db") + db, err := skilldb.Open(dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestUpsertSkill_InsertAndUpdate(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + now := time.Now().UTC().Truncate(time.Second) + + // Insert a new skill. + skill := skilldb.SkillRow{ + Name: "go-linting", + SourceAgent: "claude-code", + Path: ".codex/skills/go-linting/SKILL.md", + Kind: "project", + DiscoveredAt: now, + LastSeenAt: now, + } + require.NoError(t, db.UpsertSkill(ctx, skill)) + + skills, err := db.ListSkills(ctx) + require.NoError(t, err) + require.Len(t, skills, 1) + assert.Equal(t, "go-linting", skills[0].Name) + assert.Equal(t, ".codex/skills/go-linting/SKILL.md", skills[0].Path) + + // Upsert with different path and later last_seen_at. + later := now.Add(time.Hour) + updated := skilldb.SkillRow{ + Name: "go-linting", + SourceAgent: "claude-code", + Path: ".codex/skills/go-linting/v2/SKILL.md", + Kind: "project", + DiscoveredAt: later, // Should NOT overwrite original discovered_at + LastSeenAt: later, + } + require.NoError(t, db.UpsertSkill(ctx, updated)) + + skills, err = db.ListSkills(ctx) + require.NoError(t, err) + require.Len(t, skills, 1) + assert.Equal(t, ".codex/skills/go-linting/v2/SKILL.md", skills[0].Path, "path should be updated") + assert.Equal(t, now, skills[0].DiscoveredAt, "discovered_at should be preserved") + assert.Equal(t, later, skills[0].LastSeenAt, "last_seen_at should be updated") +} + +func TestListSkills(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + skills := []skilldb.SkillRow{ + {Name: "alpha-skill", SourceAgent: "claude-code", Path: "/a", Kind: "project", DiscoveredAt: now, LastSeenAt: now}, + {Name: "beta-skill", SourceAgent: "gemini-cli", Path: "/b", Kind: "global", DiscoveredAt: now, LastSeenAt: now}, + } + for _, s := range skills { + require.NoError(t, db.UpsertSkill(ctx, s)) + } + + result, err := db.ListSkills(ctx) + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "alpha-skill", result[0].Name) + assert.Equal(t, "beta-skill", result[1].Name) +} + +func TestSkillStats_Aggregation(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + sessions := []skilldb.SkillSessionRow{ + {SkillName: "test-skill", SourceAgent: "claude-code", CheckpointID: "chk-1", SessionIndex: 0, CreatedAt: base, TotalTokens: 1000, OverallScore: 0.8, FrictionCount: 2}, + {SkillName: "test-skill", SourceAgent: "claude-code", CheckpointID: "chk-2", SessionIndex: 0, CreatedAt: base.Add(24 * time.Hour), TotalTokens: 2000, OverallScore: 0.6, FrictionCount: 1}, + {SkillName: "test-skill", SourceAgent: "claude-code", CheckpointID: "chk-3", SessionIndex: 0, CreatedAt: base.Add(48 * time.Hour), TotalTokens: 1500, OverallScore: 0.9, FrictionCount: 0}, + } + for _, s := range sessions { + require.NoError(t, db.InsertSession(ctx, s)) + } + + stats, err := db.SkillStats(ctx, "test-skill", "claude-code") + require.NoError(t, err) + assert.Equal(t, 3, stats.TotalSessions) + assert.InDelta(t, (0.8+0.6+0.9)/3, stats.AvgScore, 0.01) + assert.Equal(t, 3, stats.TotalFriction) + assert.Equal(t, int64(4500), stats.TotalTokens) + assert.Equal(t, base, stats.FirstUsed) + assert.Equal(t, base.Add(48*time.Hour), stats.LastUsed) +} + +func TestRecentSessions(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + for i := range 5 { + s := skilldb.SkillSessionRow{ + SkillName: "test-skill", + SourceAgent: "claude-code", + CheckpointID: "chk-" + string(rune('A'+i)), + SessionIndex: 0, + CreatedAt: base.Add(time.Duration(i) * time.Hour), + Agent: "claude-code", + } + require.NoError(t, db.InsertSession(ctx, s)) + } + + sessions, err := db.RecentSessions(ctx, "test-skill", "claude-code", 3) + require.NoError(t, err) + require.Len(t, sessions, 3) + + // Should be ordered newest first. + for i := 1; i < len(sessions); i++ { + assert.True(t, sessions[i-1].CreatedAt.After(sessions[i].CreatedAt), + "sessions should be ordered newest first") + } +} + +func TestSkillFrictionThemes_Grouping(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + // Insert friction with same text across different checkpoints. + require.NoError(t, db.InsertFriction(ctx, "test-skill", "claude-code", "chk-1", 0, "lint failed", "tooling")) + require.NoError(t, db.InsertFriction(ctx, "test-skill", "claude-code", "chk-2", 0, "lint failed", "tooling")) + require.NoError(t, db.InsertFriction(ctx, "test-skill", "claude-code", "chk-3", 0, "unique issue", "")) + + themes, err := db.SkillFrictionThemes(ctx, "test-skill", "claude-code") + require.NoError(t, err) + require.Len(t, themes, 2) + + // "lint failed" should be first (higher count). + assert.Equal(t, "lint failed", themes[0].Text) + assert.Equal(t, 2, themes[0].Count) + assert.Len(t, themes[0].Sessions, 2) + + assert.Equal(t, "unique issue", themes[1].Text) + assert.Equal(t, 1, themes[1].Count) +} + +func TestSkillMissingInstructions_Grouping(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + require.NoError(t, db.InsertMissingInstruction(ctx, "test-skill", "claude-code", "chk-1", 0, "run tests first", "user asked twice")) + require.NoError(t, db.InsertMissingInstruction(ctx, "test-skill", "claude-code", "chk-2", 0, "run tests first", "user reminded again")) + + instructions, err := db.SkillMissingInstructions(ctx, "test-skill", "claude-code") + require.NoError(t, err) + require.Len(t, instructions, 1) + assert.Equal(t, "run tests first", instructions[0].Instruction) + assert.Equal(t, 2, instructions[0].Count) + assert.Len(t, instructions[0].Sessions, 2) +} + +func TestAgentBreakdown(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + sessions := []skilldb.SkillSessionRow{ + {SkillName: "test-skill", SourceAgent: "claude-code", CheckpointID: "chk-1", SessionIndex: 0, Agent: "claude-code", CreatedAt: now, TotalTokens: 1000, OverallScore: 0.8}, + {SkillName: "test-skill", SourceAgent: "claude-code", CheckpointID: "chk-2", SessionIndex: 0, Agent: "claude-code", CreatedAt: now, TotalTokens: 2000, OverallScore: 0.6}, + {SkillName: "test-skill", SourceAgent: "claude-code", CheckpointID: "chk-3", SessionIndex: 0, Agent: "gemini-cli", CreatedAt: now, TotalTokens: 1500, OverallScore: 0.9}, + } + for _, s := range sessions { + require.NoError(t, db.InsertSession(ctx, s)) + } + + breakdown, err := db.AgentBreakdown(ctx, "test-skill", "claude-code") + require.NoError(t, err) + require.Len(t, breakdown, 2) + + // claude-code should be first (more sessions). + assert.Equal(t, "claude-code", breakdown[0].Agent) + assert.Equal(t, 2, breakdown[0].SessionCount) + assert.Equal(t, int64(3000), breakdown[0].TotalTokens) + + assert.Equal(t, "gemini-cli", breakdown[1].Agent) + assert.Equal(t, 1, breakdown[1].SessionCount) +} + +func TestInsertAndListImprovements(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + improvements := []skilldb.SkillImprovement{ + {ID: "imp-1", SkillName: "test-skill", SourceAgent: "claude-code", Title: "Add lint check", Priority: "high", Status: "pending", CreatedAt: now}, + {ID: "imp-2", SkillName: "test-skill", SourceAgent: "claude-code", Title: "Fix typo", Priority: "low", Status: "applied", CreatedAt: now, AppliedAt: &now}, + } + for _, imp := range improvements { + require.NoError(t, db.InsertImprovement(ctx, imp)) + } + + // List all. + all, err := db.ListImprovements(ctx, "test-skill", "claude-code", "") + require.NoError(t, err) + assert.Len(t, all, 2) + + // List pending only. + pending, err := db.ListImprovements(ctx, "test-skill", "claude-code", "pending") + require.NoError(t, err) + require.Len(t, pending, 1) + assert.Equal(t, "imp-1", pending[0].ID) + + // List applied only. + applied, err := db.ListImprovements(ctx, "test-skill", "claude-code", "applied") + require.NoError(t, err) + require.Len(t, applied, 1) + assert.Equal(t, "imp-2", applied[0].ID) +} + +func TestUpdateImprovementStatus(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + imp := skilldb.SkillImprovement{ + ID: "imp-update", + SkillName: "test-skill", + SourceAgent: "claude-code", + Title: "Improve error handling", + Priority: "medium", + Status: "pending", + CreatedAt: now, + } + require.NoError(t, db.InsertImprovement(ctx, imp)) + + // Update to applied. + require.NoError(t, db.UpdateImprovementStatus(ctx, "imp-update", "applied")) + + improvements, err := db.ListImprovements(ctx, "test-skill", "claude-code", "applied") + require.NoError(t, err) + require.Len(t, improvements, 1) + assert.Equal(t, "applied", improvements[0].Status) + assert.NotNil(t, improvements[0].AppliedAt) +} + +func TestCacheTip_GetAndSet(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + + // Empty initially. + tip, err := db.GetCacheTip(ctx) + require.NoError(t, err) + assert.Empty(t, tip) + + // Set and get. + require.NoError(t, db.SetCacheTip(ctx, "abc123")) + tip, err = db.GetCacheTip(ctx) + require.NoError(t, err) + assert.Equal(t, "abc123", tip) + + // Overwrite. + require.NoError(t, db.SetCacheTip(ctx, "def456")) + tip, err = db.GetCacheTip(ctx) + require.NoError(t, err) + assert.Equal(t, "def456", tip) +} + +func TestBeginTx_BulkInserts(t *testing.T) { + t.Parallel() + + db := openTestDB(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + tx, err := db.BeginTx(ctx) + require.NoError(t, err) + + for i := range 3 { + row := skilldb.SkillSessionRow{ + SkillName: "bulk-skill", + SourceAgent: "claude-code", + CheckpointID: "chk-bulk-" + string(rune('A'+i)), + SessionIndex: 0, + CreatedAt: now, + } + require.NoError(t, db.InsertSessionTx(ctx, tx, row)) + require.NoError(t, db.InsertFrictionTx(ctx, tx, "bulk-skill", "claude-code", row.CheckpointID, 0, "friction text", "category")) + require.NoError(t, db.InsertMissingInstructionTx(ctx, tx, "bulk-skill", "claude-code", row.CheckpointID, 0, "instruction", "evidence")) + } + + require.NoError(t, tx.Commit()) + + sessions, err := db.RecentSessions(ctx, "bulk-skill", "claude-code", 10) + require.NoError(t, err) + assert.Len(t, sessions, 3) + + themes, err := db.SkillFrictionThemes(ctx, "bulk-skill", "claude-code") + require.NoError(t, err) + assert.Len(t, themes, 1) + assert.Equal(t, 3, themes[0].Count) +} diff --git a/cmd/entire/cli/skillimprove/applier.go b/cmd/entire/cli/skillimprove/applier.go new file mode 100644 index 000000000..6cf0d96eb --- /dev/null +++ b/cmd/entire/cli/skillimprove/applier.go @@ -0,0 +1,223 @@ +package skillimprove + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Hunk represents a single hunk in a unified diff. +type Hunk struct { + OldStart int + OldCount int + NewStart int + NewCount int + Lines []DiffLine +} + +// DiffLine is a single line within a diff hunk. +type DiffLine struct { + Kind rune // ' ' for context, '+' for addition, '-' for removal + Text string +} + +// ApplyDiff reads the file at filePath, applies the unified diff, and writes +// the result back. It returns a descriptive error if context lines do not match. +func ApplyDiff(filePath, diffText string) error { + content, err := os.ReadFile(filePath) //nolint:gosec // filePath is caller-controlled, not user input + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + + hunks, err := ParseHunks(diffText) + if err != nil { + return fmt.Errorf("parsing diff: %w", err) + } + + if len(hunks) == 0 { + return nil // nothing to apply + } + + lines := splitLines(string(content)) + result, err := applyHunks(lines, hunks) + if err != nil { + return err + } + + if err := os.WriteFile(filePath, []byte(joinLines(result)), 0o600); err != nil { //nolint:gosec // filePath is caller-controlled, not user input + return fmt.Errorf("writing file: %w", err) + } + return nil +} + +// ParseHunks extracts hunks from a unified diff string. +func ParseHunks(diffText string) ([]Hunk, error) { + rawLines := strings.Split(diffText, "\n") + + var hunks []Hunk + var current *Hunk + + for _, line := range rawLines { + // Skip file headers. + if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "diff ") { + continue + } + + // Hunk header. + if strings.HasPrefix(line, "@@ ") { + h, err := parseHunkHeader(line) + if err != nil { + return nil, err + } + hunks = append(hunks, h) + current = &hunks[len(hunks)-1] + continue + } + + if current == nil { + continue // skip lines before first hunk + } + + if len(line) == 0 { + // Empty line in a diff is treated as a context line with empty text. + current.Lines = append(current.Lines, DiffLine{Kind: ' ', Text: ""}) + continue + } + + kind := rune(line[0]) + text := line[1:] + + switch kind { + case ' ', '+', '-': + current.Lines = append(current.Lines, DiffLine{Kind: kind, Text: text}) + case '\\': + // "\ No newline at end of file" — skip. + continue + default: + // Treat unrecognised lines as context (some diffs omit the leading space). + current.Lines = append(current.Lines, DiffLine{Kind: ' ', Text: line}) + } + } + + return hunks, nil +} + +// parseHunkHeader parses a line like "@@ -1,3 +1,4 @@" or "@@ -1 +1,2 @@". +func parseHunkHeader(line string) (Hunk, error) { + // Strip the @@ markers and any trailing section heading. + line = strings.TrimPrefix(line, "@@ ") + if idx := strings.Index(line, " @@"); idx != -1 { + line = line[:idx] + } + + parts := strings.Fields(line) + if len(parts) < 2 { + return Hunk{}, fmt.Errorf("invalid hunk header: %q", line) + } + + oldStart, oldCount, err := parseRange(parts[0]) + if err != nil { + return Hunk{}, fmt.Errorf("invalid old range %q: %w", parts[0], err) + } + + newStart, newCount, err := parseRange(parts[1]) + if err != nil { + return Hunk{}, fmt.Errorf("invalid new range %q: %w", parts[1], err) + } + + return Hunk{OldStart: oldStart, OldCount: oldCount, NewStart: newStart, NewCount: newCount}, nil +} + +// parseRange parses "-1,3" or "+1,3" or "-1" into (start, count). +func parseRange(s string) (int, int, error) { + s = strings.TrimLeft(s, "+-") + if idx := strings.Index(s, ","); idx != -1 { + start, err := strconv.Atoi(s[:idx]) + if err != nil { + return 0, 0, fmt.Errorf("parsing start: %w", err) + } + count, err := strconv.Atoi(s[idx+1:]) + if err != nil { + return 0, 0, fmt.Errorf("parsing count: %w", err) + } + return start, count, nil + } + + start, err := strconv.Atoi(s) + if err != nil { + return 0, 0, fmt.Errorf("parsing range: %w", err) + } + return start, 1, nil +} + +// applyHunks applies parsed hunks to the source lines. +// Hunks must be in order of ascending OldStart. +func applyHunks(lines []string, hunks []Hunk) ([]string, error) { + var result []string + srcIdx := 0 // 0-based index into lines + + for _, h := range hunks { + hunkStart := h.OldStart - 1 // convert to 0-based + + // Copy lines before this hunk. + if hunkStart > srcIdx { + result = append(result, lines[srcIdx:hunkStart]...) + } + srcIdx = hunkStart + + for _, dl := range h.Lines { + switch dl.Kind { + case ' ': + // Context line: verify and copy. + if srcIdx >= len(lines) { + return nil, fmt.Errorf("diff context mismatch at line %d: expected %q, got end of file", srcIdx+1, dl.Text) + } + if lines[srcIdx] != dl.Text { + return nil, fmt.Errorf("diff context mismatch at line %d: expected %q, got %q", srcIdx+1, dl.Text, lines[srcIdx]) + } + result = append(result, lines[srcIdx]) + srcIdx++ + + case '-': + // Removal: verify and skip. + if srcIdx >= len(lines) { + return nil, fmt.Errorf("diff context mismatch at line %d: expected %q, got end of file", srcIdx+1, dl.Text) + } + if lines[srcIdx] != dl.Text { + return nil, fmt.Errorf("diff context mismatch at line %d: expected %q, got %q", srcIdx+1, dl.Text, lines[srcIdx]) + } + srcIdx++ + + case '+': + // Addition: insert. + result = append(result, dl.Text) + } + } + } + + // Copy remaining lines after the last hunk. + if srcIdx < len(lines) { + result = append(result, lines[srcIdx:]...) + } + + return result, nil +} + +// splitLines splits content into lines. A trailing newline does not produce an +// extra empty element. +func splitLines(s string) []string { + if s == "" { + return nil + } + s = strings.TrimSuffix(s, "\n") + return strings.Split(s, "\n") +} + +// joinLines joins lines back together with newlines, adding a trailing newline. +func joinLines(lines []string) string { + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/cmd/entire/cli/skillimprove/applier_test.go b/cmd/entire/cli/skillimprove/applier_test.go new file mode 100644 index 000000000..b70ac4c55 --- /dev/null +++ b/cmd/entire/cli/skillimprove/applier_test.go @@ -0,0 +1,212 @@ +package skillimprove_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/skillimprove" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTestFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "file.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} + +func TestApplyDiff_SimpleAdd(t *testing.T) { + t.Parallel() + + path := writeTestFile(t, "line1\nline2\nline3\n") + + diff := `--- a/file.md ++++ b/file.md +@@ -1,3 +1,4 @@ + line1 + line2 ++newline + line3` + + err := skillimprove.ApplyDiff(path, diff) + require.NoError(t, err) + + got := readTestFile(t, path) + assert.Equal(t, "line1\nline2\nnewline\nline3\n", got) +} + +func TestApplyDiff_SimpleRemove(t *testing.T) { + t.Parallel() + + path := writeTestFile(t, "line1\nline2\nline3\n") + + diff := `--- a/file.md ++++ b/file.md +@@ -1,3 +1,2 @@ + line1 +-line2 + line3` + + err := skillimprove.ApplyDiff(path, diff) + require.NoError(t, err) + + got := readTestFile(t, path) + assert.Equal(t, "line1\nline3\n", got) +} + +func TestApplyDiff_Replace(t *testing.T) { + t.Parallel() + + path := writeTestFile(t, "line1\nline2\nline3\n") + + diff := `--- a/file.md ++++ b/file.md +@@ -1,3 +1,3 @@ + line1 +-line2 ++replaced + line3` + + err := skillimprove.ApplyDiff(path, diff) + require.NoError(t, err) + + got := readTestFile(t, path) + assert.Equal(t, "line1\nreplaced\nline3\n", got) +} + +func TestApplyDiff_ContextMismatch(t *testing.T) { + t.Parallel() + + path := writeTestFile(t, "line1\nline2\nline3\n") + + diff := `--- a/file.md ++++ b/file.md +@@ -1,3 +1,4 @@ + line1 + wrong_context ++newline + line3` + + err := skillimprove.ApplyDiff(path, diff) + require.Error(t, err) + assert.Contains(t, err.Error(), "diff context mismatch") + assert.Contains(t, err.Error(), "wrong_context") + assert.Contains(t, err.Error(), "line2") +} + +func TestApplyDiff_MultipleHunks(t *testing.T) { + t.Parallel() + + path := writeTestFile(t, "aaa\nbbb\nccc\nddd\neee\nfff\n") + + diff := `--- a/file.md ++++ b/file.md +@@ -1,3 +1,4 @@ + aaa ++inserted1 + bbb + ccc +@@ -5,2 +6,3 @@ + eee ++inserted2 + fff` + + err := skillimprove.ApplyDiff(path, diff) + require.NoError(t, err) + + got := readTestFile(t, path) + assert.Equal(t, "aaa\ninserted1\nbbb\nccc\nddd\neee\ninserted2\nfff\n", got) +} + +func TestApplyDiff_EmptyDiff(t *testing.T) { + t.Parallel() + + path := writeTestFile(t, "line1\nline2\n") + + err := skillimprove.ApplyDiff(path, "") + require.NoError(t, err) + + got := readTestFile(t, path) + assert.Equal(t, "line1\nline2\n", got) +} + +func TestApplyDiff_FileNotFound(t *testing.T) { + t.Parallel() + + err := skillimprove.ApplyDiff("/nonexistent/path/file.md", "@@ -1,1 +1,2 @@\n line1\n+line2") + require.Error(t, err) + assert.Contains(t, err.Error(), "reading file") +} + +func TestParseHunks_SingleHunk(t *testing.T) { + t.Parallel() + + diff := `--- a/file.md ++++ b/file.md +@@ -1,3 +1,4 @@ + line1 + line2 ++newline + line3` + + hunks, err := skillimprove.ParseHunks(diff) + require.NoError(t, err) + require.Len(t, hunks, 1) + + h := hunks[0] + assert.Equal(t, 1, h.OldStart) + assert.Equal(t, 3, h.OldCount) + assert.Equal(t, 1, h.NewStart) + assert.Equal(t, 4, h.NewCount) + assert.Len(t, h.Lines, 4) + + assert.Equal(t, ' ', h.Lines[0].Kind) + assert.Equal(t, "line1", h.Lines[0].Text) + + assert.Equal(t, '+', h.Lines[2].Kind) + assert.Equal(t, "newline", h.Lines[2].Text) +} + +func TestParseHunks_MultipleHunks(t *testing.T) { + t.Parallel() + + diff := `@@ -1,2 +1,3 @@ + first ++added + second +@@ -5,2 +6,2 @@ +-old ++new + last` + + hunks, err := skillimprove.ParseHunks(diff) + require.NoError(t, err) + require.Len(t, hunks, 2) + + assert.Equal(t, 1, hunks[0].OldStart) + assert.Equal(t, 2, hunks[0].OldCount) + assert.Equal(t, 1, hunks[0].NewStart) + assert.Equal(t, 3, hunks[0].NewCount) + + assert.Equal(t, 5, hunks[1].OldStart) + assert.Equal(t, 2, hunks[1].OldCount) + assert.Equal(t, 6, hunks[1].NewStart) + assert.Equal(t, 2, hunks[1].NewCount) +} + +func TestParseHunks_InvalidHeader(t *testing.T) { + t.Parallel() + + _, err := skillimprove.ParseHunks("@@ invalid @@") + require.Error(t, err) +} diff --git a/cmd/entire/cli/skillimprove/generator.go b/cmd/entire/cli/skillimprove/generator.go new file mode 100644 index 000000000..0af3eda19 --- /dev/null +++ b/cmd/entire/cli/skillimprove/generator.go @@ -0,0 +1,185 @@ +// Package skillimprove generates AI-powered improvement suggestions for skill +// files and applies unified diffs to update them. +package skillimprove + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/llmcli" +) + +// SkillImprovementRequest contains the data needed to generate improvement +// suggestions for a single skill file. +type SkillImprovementRequest struct { + SkillName string + SkillPath string + SkillContent string + FrictionThemes []FrictionTheme + MissingInstructions []MissingInstruction + TranscriptExcerpts []string + TotalSessions int + FrictionRate float64 + AgentBreakdown []AgentStats +} + +// FrictionTheme represents a categorized friction pattern observed across sessions. +type FrictionTheme struct { + Text string + Category string + Count int +} + +// MissingInstruction represents an instruction the agent needed but was not +// present in the skill file. +type MissingInstruction struct { + Instruction string + Count int + Evidence []string +} + +// AgentStats holds per-agent session statistics for a skill. +type AgentStats struct { + Agent string + SessionCount int + AvgScore float64 +} + +// SkillSuggestion is a single actionable improvement for a skill file. +type SkillSuggestion struct { + Title string `json:"title"` + Description string `json:"description"` + Priority string `json:"priority"` + Diff string `json:"diff"` + Evidence []string `json:"evidence"` + SourceSessionIDs []string `json:"source_session_ids,omitempty"` +} + +// GenerateResult holds the suggestions and token usage from a Generate call. +type GenerateResult struct { + Suggestions []SkillSuggestion + Usage *llmcli.UsageInfo +} + +// Generator produces skill improvement suggestions via the Claude CLI. +type Generator struct { + Runner *llmcli.Runner +} + +// generateResponse is the expected JSON structure returned by the Claude CLI. +type generateResponse struct { + Suggestions []SkillSuggestion `json:"suggestions"` +} + +// Generate produces improvement suggestions for a skill file based on usage data. +func (g *Generator) Generate(ctx context.Context, req SkillImprovementRequest) (*GenerateResult, error) { + prompt := BuildSkillPrompt(req) + + if g.Runner == nil { + g.Runner = &llmcli.Runner{} + } + + raw, usage, err := g.Runner.Execute(ctx, prompt) + if err != nil { + return nil, fmt.Errorf("failed to execute skill improvement prompt: %w", err) + } + + var resp generateResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return nil, fmt.Errorf("failed to parse skill improvement suggestions: %w", err) + } + + return &GenerateResult{Suggestions: resp.Suggestions, Usage: usage}, nil +} + +// BuildSkillPrompt constructs the prompt for the Claude CLI. +// All untrusted content (skill content, friction text, transcript excerpts) is +// wrapped in XML tags to prevent prompt injection. +func BuildSkillPrompt(req SkillImprovementRequest) string { + var sb strings.Builder + + sb.WriteString(`You are a skill improvement analyst. Analyze the usage data for a CLI skill and suggest specific improvements to the skill file. + +`) + + // Skill info section (always present). + sb.WriteString("\n") + fmt.Fprintf(&sb, "Name: %s\n", req.SkillName) + fmt.Fprintf(&sb, "Path: %s\n", req.SkillPath) + fmt.Fprintf(&sb, "Total sessions: %d\n", req.TotalSessions) + fmt.Fprintf(&sb, "Friction rate: %.0f%%\n", req.FrictionRate) + sb.WriteString("\n\n") + + // Skill content section (always present). + sb.WriteString("\n") + sb.WriteString(req.SkillContent) + sb.WriteString("\n\n\n") + + // Friction patterns section (omitted when empty). + if len(req.FrictionThemes) > 0 { + sb.WriteString("\n") + for _, theme := range req.FrictionThemes { + fmt.Fprintf(&sb, "- [%s] %s (occurred %d times)\n", theme.Category, theme.Text, theme.Count) + } + sb.WriteString("\n\n") + } + + // Missing instructions section (omitted when empty). + if len(req.MissingInstructions) > 0 { + sb.WriteString("\n") + for _, mi := range req.MissingInstructions { + fmt.Fprintf(&sb, "- %s (reported %d times)\n", mi.Instruction, mi.Count) + if len(mi.Evidence) > 0 { + fmt.Fprintf(&sb, " Evidence: %s\n", strings.Join(mi.Evidence, "; ")) + } + } + sb.WriteString("\n\n") + } + + // Transcript excerpts section (omitted when empty). + if len(req.TranscriptExcerpts) > 0 { + sb.WriteString("\n") + for _, excerpt := range req.TranscriptExcerpts { + sb.WriteString("--- Excerpt ---\n") + sb.WriteString(excerpt) + sb.WriteString("\n") + } + sb.WriteString("\n\n") + } + + // Agent breakdown section (omitted when empty). + if len(req.AgentBreakdown) > 0 { + sb.WriteString("\n") + for _, a := range req.AgentBreakdown { + fmt.Fprintf(&sb, "- %s: %d sessions, avg score %.0f\n", a.Agent, a.SessionCount, a.AvgScore) + } + sb.WriteString("\n\n") + } + + sb.WriteString(`Respond with JSON only, no markdown fencing: +{ + "suggestions": [ + { + "title": "Short improvement title", + "description": "Why this improvement helps, based on evidence", + "priority": "high|medium|low", + "diff": "unified diff against the skill file", + "evidence": ["quote from transcript or friction item"], + "source_session_ids": ["session-id-1"] + } + ] +} + +Rules: +- Generate 1-5 suggestions, prioritized by impact +- Each diff must be a valid unified diff that can be applied to the skill file +- High priority: friction occurring 3+ times or missing instructions reported 3+ times +- Medium priority: friction occurring 2 times +- Low priority: single-occurrence improvements or style suggestions +- Focus on actionable changes to the skill file content, not structural changes +`) + + return sb.String() +} diff --git a/cmd/entire/cli/skillimprove/generator_test.go b/cmd/entire/cli/skillimprove/generator_test.go new file mode 100644 index 000000000..65205356a --- /dev/null +++ b/cmd/entire/cli/skillimprove/generator_test.go @@ -0,0 +1,190 @@ +package skillimprove_test + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/llmcli" + "github.com/entireio/cli/cmd/entire/cli/skillimprove" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildCLIResponse wraps a result string in the Claude CLI JSON envelope. +func buildCLIResponse(result string) string { + b, err := json.Marshal(result) + if err != nil { + panic(fmt.Sprintf("failed to marshal result: %v", err)) + } + return fmt.Sprintf(`{"result":%s}`, string(b)) +} + +func TestBuildSkillPrompt_ContainsSections(t *testing.T) { + t.Parallel() + + req := skillimprove.SkillImprovementRequest{ + SkillName: "deploy", + SkillPath: "/skills/deploy/SKILL.md", + SkillContent: "# Deploy Skill\nRun deploy commands.", + FrictionThemes: []skillimprove.FrictionTheme{ + {Text: "deploy fails silently", Category: "reliability", Count: 3}, + }, + MissingInstructions: []skillimprove.MissingInstruction{ + {Instruction: "specify environment", Count: 2, Evidence: []string{"forgot staging", "no env flag"}}, + }, + TranscriptExcerpts: []string{"User: deploy broke\nAgent: retrying..."}, + TotalSessions: 10, + FrictionRate: 30.0, + AgentBreakdown: []skillimprove.AgentStats{ + {Agent: "Claude Code", SessionCount: 7, AvgScore: 85}, + {Agent: "Gemini CLI", SessionCount: 3, AvgScore: 72}, + }, + } + + prompt := skillimprove.BuildSkillPrompt(req) + + assert.Contains(t, prompt, "") + assert.Contains(t, prompt, "Name: deploy") + assert.Contains(t, prompt, "Path: /skills/deploy/SKILL.md") + assert.Contains(t, prompt, "Total sessions: 10") + assert.Contains(t, prompt, "Friction rate: 30%") + + assert.Contains(t, prompt, "") + assert.Contains(t, prompt, "# Deploy Skill") + + assert.Contains(t, prompt, "") + assert.Contains(t, prompt, "[reliability] deploy fails silently (occurred 3 times)") + + assert.Contains(t, prompt, "") + assert.Contains(t, prompt, "specify environment (reported 2 times)") + assert.Contains(t, prompt, "Evidence: forgot staging; no env flag") + + assert.Contains(t, prompt, "") + assert.Contains(t, prompt, "--- Excerpt ---") + assert.Contains(t, prompt, "User: deploy broke") + + assert.Contains(t, prompt, "") + assert.Contains(t, prompt, "Claude Code: 7 sessions, avg score 85") + assert.Contains(t, prompt, "Gemini CLI: 3 sessions, avg score 72") +} + +func TestBuildSkillPrompt_OmitsEmptySections(t *testing.T) { + t.Parallel() + + req := skillimprove.SkillImprovementRequest{ + SkillName: "build", + SkillPath: "/skills/build/SKILL.md", + SkillContent: "# Build", + // All optional slices left nil. + TotalSessions: 5, + FrictionRate: 0, + } + + prompt := skillimprove.BuildSkillPrompt(req) + + // Required sections are always present. + assert.Contains(t, prompt, "") + assert.Contains(t, prompt, "") + + // Optional sections should be absent. + assert.NotContains(t, prompt, "") + assert.NotContains(t, prompt, "") + assert.NotContains(t, prompt, "") + assert.NotContains(t, prompt, "") +} + +func TestGenerate_ParsesResponse(t *testing.T) { + t.Parallel() + + inner := `{ + "suggestions": [ + { + "title": "Add error handling docs", + "description": "Multiple sessions showed confusion about error handling", + "priority": "high", + "diff": "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,2 +1,3 @@\n # Deploy\n+## Error Handling\n Run deploy.", + "evidence": ["agent retried 3 times", "user asked about errors"], + "source_session_ids": ["sess-001"] + } + ] + }` + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(inner) + // Use printf to avoid shell quoting issues with single quotes in JSON. + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat <<'ENDOFRESPONSE'\n%s\nENDOFRESPONSE", resp)) + }, + } + + gen := &skillimprove.Generator{Runner: runner} + + result, err := gen.Generate(context.Background(), skillimprove.SkillImprovementRequest{ + SkillName: "deploy", + SkillPath: "/skills/deploy/SKILL.md", + SkillContent: "# Deploy\nRun deploy.", + FrictionThemes: []skillimprove.FrictionTheme{ + {Text: "error handling unclear", Category: "docs", Count: 3}, + }, + TotalSessions: 5, + FrictionRate: 60, + }) + + require.NoError(t, err) + require.Len(t, result.Suggestions, 1) + + s := result.Suggestions[0] + assert.Equal(t, "Add error handling docs", s.Title) + assert.Equal(t, "high", s.Priority) + assert.Contains(t, s.Diff, "+## Error Handling") + assert.Equal(t, []string{"agent retried 3 times", "user asked about errors"}, s.Evidence) + assert.Equal(t, []string{"sess-001"}, s.SourceSessionIDs) +} + +func TestGenerate_HandlesEmptySuggestions(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse(`{"suggestions": []}`) + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat <<'ENDOFRESPONSE'\n%s\nENDOFRESPONSE", resp)) + }, + } + + gen := &skillimprove.Generator{Runner: runner} + + result, err := gen.Generate(context.Background(), skillimprove.SkillImprovementRequest{ + SkillName: "test", + SkillPath: "/skills/test/SKILL.md", + SkillContent: "# Test", + }) + + require.NoError(t, err) + assert.Empty(t, result.Suggestions) +} + +func TestGenerate_InvalidJSON(t *testing.T) { + t.Parallel() + + runner := &llmcli.Runner{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + resp := buildCLIResponse("not valid json at all") + return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat <<'ENDOFRESPONSE'\n%s\nENDOFRESPONSE", resp)) + }, + } + + gen := &skillimprove.Generator{Runner: runner} + + _, err := gen.Generate(context.Background(), skillimprove.SkillImprovementRequest{ + SkillName: "test", + SkillPath: "/skills/test/SKILL.md", + SkillContent: "# Test", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse skill improvement suggestions", + "expected parse error, got: %s", err) +} diff --git a/cmd/entire/cli/skilltui/keys.go b/cmd/entire/cli/skilltui/keys.go new file mode 100644 index 000000000..5d074f42c --- /dev/null +++ b/cmd/entire/cli/skilltui/keys.go @@ -0,0 +1,57 @@ +package skilltui + +import "github.com/charmbracelet/bubbles/key" + +type globalKeys struct { + TabNext key.Binding + TabPrev key.Binding + Tab1 key.Binding + Tab2 key.Binding + Tab3 key.Binding + Help key.Binding + Quit key.Binding +} + +type pickerKeys struct { + Up key.Binding + Down key.Binding + Enter key.Binding +} + +type dashboardKeys struct { + Back key.Binding + Refresh key.Binding +} + +type improveKeys struct { + Generate key.Binding + Apply key.Binding + Dismiss key.Binding +} + +var globalKeyMap = globalKeys{ + TabNext: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), + TabPrev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), + Tab1: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stats")), + Tab2: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "friction")), + Tab3: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "improve")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), +} + +var pickerKeyMap = pickerKeys{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down/j", "down")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), +} + +var dashboardKeyMap = dashboardKeys{ + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), +} + +var improveKeyMap = improveKeys{ + Generate: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "generate")), + Apply: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "apply")), + Dismiss: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dismiss")), +} diff --git a/cmd/entire/cli/skilltui/messages.go b/cmd/entire/cli/skilltui/messages.go new file mode 100644 index 000000000..44df101a1 --- /dev/null +++ b/cmd/entire/cli/skilltui/messages.go @@ -0,0 +1,68 @@ +package skilltui + +import ( + "github.com/entireio/cli/cmd/entire/cli/skilldb" + "github.com/entireio/cli/cmd/entire/cli/skillimprove" +) + +// dataLoadedMsg is sent when the skill list and stats are loaded from the database. +type dataLoadedMsg struct { + skills []skilldb.SkillRow + stats map[string]*skilldb.SkillStatsResult // keyed by "name|source_agent" + err error +} + +// skillSelectedMsg is sent when the user picks a skill in the picker. +type skillSelectedMsg struct { + skill skilldb.SkillRow +} + +// skillDetailLoadedMsg is sent when full detail data for a skill is loaded. +type skillDetailLoadedMsg struct { + stats *skilldb.SkillStatsResult + sessions []skilldb.SkillSessionRow + friction []skilldb.FrictionThemeRow + missing []skilldb.MissingInstructionRow + agents []skilldb.AgentBreakdownRow + improvements []skilldb.SkillImprovement + err error +} + +// backToPickerMsg returns to the skill picker screen. +type backToPickerMsg struct{} + +// generateStartedMsg indicates the user pressed 'g' to generate suggestions. +type generateStartedMsg struct{} + +// generateDoneMsg contains the results of the LLM generation. +type generateDoneMsg struct { + suggestions []skillimprove.SkillSuggestion + err error +} + +// applyDiffMsg requests applying a diff for the suggestion at the given index. +type applyDiffMsg struct { + index int +} + +// applyDiffResultMsg contains the result of applying a diff. +type applyDiffResultMsg struct { + index int + err error +} + +// dismissSuggestionMsg removes the suggestion at the given index. +type dismissSuggestionMsg struct { + index int +} + +// refreshMsg re-runs the initial data loading. +type refreshMsg struct{} + +// errorFlashMsg shows a temporary error message in the status bar. +type errorFlashMsg struct { + text string +} + +// clearErrorMsg clears the error flash after a timeout. +type clearErrorMsg struct{} diff --git a/cmd/entire/cli/skilltui/picker.go b/cmd/entire/cli/skilltui/picker.go new file mode 100644 index 000000000..b3fcc25da --- /dev/null +++ b/cmd/entire/cli/skilltui/picker.go @@ -0,0 +1,127 @@ +package skilltui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/entireio/cli/cmd/entire/cli/skilldb" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type pickerModel struct { + skills []skilldb.SkillRow + stats map[string]*skilldb.SkillStatsResult + selected int + styles tuiStyles + width int + height int +} + +func newPickerModel(styles tuiStyles) pickerModel { + return pickerModel{ + styles: styles, + stats: make(map[string]*skilldb.SkillStatsResult), + } +} + +func (m *pickerModel) setData(skills []skilldb.SkillRow, stats map[string]*skilldb.SkillStatsResult) { + m.skills = skills + m.stats = stats + m.selected = 0 +} + +func (m *pickerModel) setSize(w, h int) { m.width = w; m.height = h } + +func (m pickerModel) update(msg tea.Msg) (pickerModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch { + case key.Matches(keyMsg, pickerKeyMap.Up): + if m.selected > 0 { + m.selected-- + } + case key.Matches(keyMsg, pickerKeyMap.Down): + if m.selected < len(m.skills)-1 { + m.selected++ + } + case key.Matches(keyMsg, pickerKeyMap.Enter): + if len(m.skills) > 0 { + skill := m.skills[m.selected] + return m, func() tea.Msg { return skillSelectedMsg{skill: skill} } + } + } + + return m, nil +} + +func (m pickerModel) view() string { + var b strings.Builder + + b.WriteString(renderPickerHeader(m.styles)) + b.WriteString("\n") + + if len(m.skills) == 0 { + b.WriteString(" No skills found. Create skill files in .claude/skills/ or .gemini/agents/\n") + return b.String() + } + + // Column headers + header := fmt.Sprintf(" %-20s %-14s %8s %10s %9s", "Name", "Source", "Sessions", "Freq", "Avg Score") + b.WriteString(m.styles.render(m.styles.dim, header)) + b.WriteString("\n") + b.WriteString(m.styles.render(m.styles.dim, strings.Repeat("\u2500", min(m.width, 70)))) + b.WriteString("\n") + + for i, skill := range m.skills { + statsKey := skill.Name + "|" + skill.SourceAgent + st := m.stats[statsKey] + + marker := " " + nameStyle := m.styles.dim + if i == m.selected { + marker = m.styles.render(m.styles.selected, "\u25b8 ") + nameStyle = m.styles.bold + } + + sessions := 0 + freqStr := m.styles.render(m.styles.dim, "\u2500 0.0/wk") + scoreStr := "\u2500" + if st != nil { + sessions = st.TotalSessions + freqStr = formatFrequency(m.styles, st.SessionsPerWeek) + if st.TotalSessions > 0 { + scoreStr = fmt.Sprintf("%.0f", st.AvgScore) + } + } + + line := fmt.Sprintf("%s%-20s %-14s %8d %10s %9s", + marker, + m.styles.render(nameStyle, skill.Name), + skill.SourceAgent, + sessions, + freqStr, + scoreStr, + ) + b.WriteString(line) + b.WriteString("\n") + } + + return b.String() +} + +func formatFrequency(s tuiStyles, perWeek float64) string { + rate := fmt.Sprintf("%.1f/wk", perWeek) + switch { + case perWeek > 1.0: + return s.render(s.success, "\u25b2") + " " + rate + case perWeek < 0.5: + return s.render(s.friction, "\u25bc") + " " + rate + default: + return s.render(s.dim, "\u2500") + " " + rate + } +} diff --git a/cmd/entire/cli/skilltui/render.go b/cmd/entire/cli/skilltui/render.go new file mode 100644 index 000000000..46916f0ad --- /dev/null +++ b/cmd/entire/cli/skilltui/render.go @@ -0,0 +1,113 @@ +package skilltui + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +var tabNames = [3]string{"Stats", "Friction", "Improve"} + +func renderTabBar(s tuiStyles, activeTab int, _ int, skillName string) string { + var b strings.Builder + + // App title + b.WriteString(s.render(s.appTitle, "SKILL IMPROVEMENT")) + b.WriteString(" ") + + titleWidth := len("SKILL IMPROVEMENT") + 2 + + // Skill name indicator + skillLabel := fmt.Sprintf("\u2039 %s \u203a", skillName) + b.WriteString(s.render(s.dim, skillLabel)) + b.WriteString(" ") + titleWidth += len(skillLabel) + 3 + + // Track position for underline + activeStart := 0 + activeWidth := 0 + pos := titleWidth + + for i, name := range tabNames { + label := fmt.Sprintf("%d %s", i+1, name) + if i == activeTab { + activeStart = pos + activeWidth = len(label) + b.WriteString(s.render(s.tabActive, label)) + } else { + b.WriteString(s.render(s.tabInactive, label)) + } + pos += len(label) + if i < len(tabNames)-1 { + b.WriteString(" ") + pos += 3 + } + } + + line1 := b.String() + + // Underline row: amber chars aligned under the active tab + line2 := strings.Repeat(" ", activeStart) + + s.render(s.tabUnderline, strings.Repeat("\u2500", activeWidth)) + + return line1 + "\n" + line2 +} + +func renderPickerHeader(s tuiStyles) string { + var b strings.Builder + + b.WriteString(s.render(s.appTitle, "SKILL IMPROVEMENT ENGINE")) + b.WriteString("\n") + + return b.String() +} + +func renderStatusBar(s tuiStyles, hints string, info string, width int) string { + hintsLen := len(hints) + infoLen := len(info) + padding := width - hintsLen - infoLen + if padding < 1 { + padding = 1 + } + return s.render(s.statusBar, hints) + strings.Repeat(" ", padding) + s.render(s.dim, info) +} + +func formatTokens(tokens int) string { + switch { + case tokens >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + case tokens >= 1_000: + return fmt.Sprintf("%dK", tokens/1_000) + default: + return strconv.Itoa(tokens) + } +} + +func formatTokens64(tokens int64) string { + return formatTokens(int(tokens)) +} + +func timeAgo(t time.Time) string { + if t.IsZero() { + return "never" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +func formatDate(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02") +} diff --git a/cmd/entire/cli/skilltui/root.go b/cmd/entire/cli/skilltui/root.go new file mode 100644 index 000000000..6cb63f14f --- /dev/null +++ b/cmd/entire/cli/skilltui/root.go @@ -0,0 +1,635 @@ +package skilltui + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/entireio/cli/cmd/entire/cli/insightsdb" + "github.com/entireio/cli/cmd/entire/cli/skilldb" + "github.com/entireio/cli/cmd/entire/cli/skillimprove" +) + +const ( + screenPicker = 0 + screenDashboard = 1 + + tabStats = 0 + tabFriction = 1 + tabImprove = 2 + + maxWidth = 120 +) + +//nolint:recvcheck // bubbletea pattern: value receivers for interface, pointer for mutation +type rootModel struct { + ctx context.Context + screen int + activeTab int + + // Current skill + selectedSkill *skilldb.SkillRow + + // Data sources + skillDB *skilldb.SkillDB + insightsDB *insightsdb.InsightsDB + generator *skillimprove.Generator + repoRoot string + + // Sub-models + picker pickerModel + statsTab statsModel + frictionTab frictionModel + improveTab improveModel + + // UI state + styles tuiStyles + width int + height int + showHelp bool + errFlash string + err error +} + +// Run launches the skill improvement TUI program. +func Run(ctx context.Context, skillDBPath, insightsDBPath, repoRoot string) error { + sdb, err := skilldb.Open(skillDBPath) + if err != nil { + return fmt.Errorf("open skill database: %w", err) + } + defer sdb.Close() + + idb, err := insightsdb.Open(insightsDBPath) + if err != nil { + return fmt.Errorf("open insights database: %w", err) + } + defer idb.Close() + + styles := newStyles() + m := rootModel{ + ctx: ctx, + styles: styles, + width: maxWidth, + skillDB: sdb, + insightsDB: idb, + generator: &skillimprove.Generator{}, + repoRoot: repoRoot, + picker: newPickerModel(styles), + improveTab: newImproveModel(styles), + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + _, runErr := p.Run() + if runErr != nil { + return fmt.Errorf("run TUI: %w", runErr) + } + return nil +} + +func (m rootModel) Init() tea.Cmd { + return m.loadData() +} + +func (m rootModel) loadData() tea.Cmd { + return func() tea.Msg { + // Discover skills on disk. + discovered, err := skilldb.DiscoverSkills(m.repoRoot) + if err != nil { + return dataLoadedMsg{err: fmt.Errorf("discover skills: %w", err)} + } + + // Convert discovered skills to SkillRow for refresh. + rows := make([]skilldb.SkillRow, len(discovered)) + now := time.Now().UTC() + for i, d := range discovered { + rows[i] = skilldb.SkillRow{ + Name: d.Name, + SourceAgent: d.SourceAgent, + Path: d.Path, + Kind: d.Kind, + DiscoveredAt: now, + LastSeenAt: now, + } + } + + // Refresh cache from insightsdb. + if _, err = m.skillDB.RefreshFromInsightsDB(m.ctx, m.insightsDB, rows); err != nil { + return dataLoadedMsg{err: fmt.Errorf("refresh skill cache: %w", err)} + } + + // Load all skills from database. + skills, err := m.skillDB.ListSkills(m.ctx) + if err != nil { + return dataLoadedMsg{err: fmt.Errorf("list skills: %w", err)} + } + + // Load stats for each skill. + stats := make(map[string]*skilldb.SkillStatsResult, len(skills)) + for _, skill := range skills { + st, statsErr := m.skillDB.SkillStats(m.ctx, skill.Name, skill.SourceAgent) + if statsErr != nil { + return dataLoadedMsg{err: fmt.Errorf("skill stats for %q: %w", skill.Name, statsErr)} + } + stats[skill.Name+"|"+skill.SourceAgent] = st + } + + return dataLoadedMsg{skills: skills, stats: stats} + } +} + +//nolint:ireturn // required by bubbletea Model interface +func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m.handleResize(msg) + case tea.KeyMsg: + return m.handleKeyMsg(msg) + case spinner.TickMsg: + return m.handleSpinnerTick(msg) + default: + return m.handleDataMsg(msg) + } +} + +//nolint:ireturn // returns tea.Model as required by bubbletea dispatch pattern +func (m rootModel) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = min(msg.Width, maxWidth) + m.height = msg.Height + m.picker.setSize(m.width, m.contentHeight()) + m.statsTab.setSize(m.width, m.contentHeight()) + m.frictionTab.setSize(m.width, m.contentHeight()) + m.improveTab.setSize(m.width, m.contentHeight()) + return m, nil +} + +//nolint:ireturn // returns tea.Model as required by bubbletea dispatch pattern +func (m rootModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Always allow ctrl+c to quit. + if key.Matches(msg, globalKeyMap.Quit) && msg.String() == "ctrl+c" { + return m, tea.Quit + } + + if m.screen == screenPicker { + return m.handlePickerKeys(msg) + } + return m.handleDashboardKeys(msg) +} + +//nolint:ireturn // returns tea.Model as required by bubbletea dispatch pattern +func (m rootModel) handlePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, globalKeyMap.Quit) { + return m, tea.Quit + } + var cmd tea.Cmd + m.picker, cmd = m.picker.update(msg) + return m, cmd +} + +//nolint:ireturn // returns tea.Model as required by bubbletea dispatch pattern +func (m rootModel) handleDashboardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, globalKeyMap.Quit): + return m, tea.Quit + case key.Matches(msg, globalKeyMap.Help): + m.showHelp = !m.showHelp + return m, nil + case key.Matches(msg, globalKeyMap.TabNext): + m.activeTab = (m.activeTab + 1) % 3 + return m, nil + case key.Matches(msg, globalKeyMap.TabPrev): + m.activeTab = (m.activeTab + 2) % 3 + return m, nil + case key.Matches(msg, globalKeyMap.Tab1): + m.activeTab = tabStats + return m, nil + case key.Matches(msg, globalKeyMap.Tab2): + m.activeTab = tabFriction + return m, nil + case key.Matches(msg, globalKeyMap.Tab3): + m.activeTab = tabImprove + return m, nil + case key.Matches(msg, dashboardKeyMap.Back): + return m, func() tea.Msg { return backToPickerMsg{} } + case key.Matches(msg, dashboardKeyMap.Refresh): + return m, func() tea.Msg { return refreshMsg{} } + default: + return m.delegateToActiveTab(msg) + } +} + +//nolint:ireturn // returns tea.Model as required by bubbletea dispatch pattern +func (m rootModel) handleSpinnerTick(msg spinner.TickMsg) (tea.Model, tea.Cmd) { + if m.improveTab.isGenerating { + var cmd tea.Cmd + m.improveTab.spinner, cmd = m.improveTab.spinner.Update(msg) + return m, cmd + } + return m, nil +} + +//nolint:ireturn,cyclop // returns tea.Model; message dispatch requires many cases +func (m rootModel) handleDataMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case dataLoadedMsg: + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.picker.setData(msg.skills, msg.stats) + return m, nil + + case skillSelectedMsg: + m.selectedSkill = &msg.skill + m.screen = screenDashboard + m.activeTab = tabStats + return m, m.loadSkillDetail(msg.skill) + + case skillDetailLoadedMsg: + if msg.err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: msg.err.Error()} } + } + m.statsTab.setData(msg.stats, msg.sessions, msg.agents) + m.frictionTab.setData(msg.friction, msg.missing) + m.improveTab.setData(msg.improvements) + return m, nil + + case backToPickerMsg: + m.screen = screenPicker + m.selectedSkill = nil + m.activeTab = tabStats + m.showHelp = false + return m, nil + + case generateStartedMsg: + m.improveTab.isGenerating = true + return m, tea.Batch(m.improveTab.spinner.Tick, m.generateSuggestions()) + + case generateDoneMsg: + m.improveTab.isGenerating = false + if msg.err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: fmt.Sprintf("generate failed: %v", msg.err)} } + } + m.improveTab.suggestions = msg.suggestions + m.improveTab.selected = 0 + return m, nil + + case applyDiffMsg: + return m, m.applyDiff(msg.index) + + case applyDiffResultMsg: + return m.handleApplyResult(msg) + + case dismissSuggestionMsg: + m.removeSuggestion(msg.index) + return m, nil + + case refreshMsg: + return m, m.loadData() + + case errorFlashMsg: + m.errFlash = msg.text + return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearErrorMsg{} }) + + case clearErrorMsg: + m.errFlash = "" + return m, nil + + default: + return m.delegateToActiveTab(msg) + } +} + +//nolint:ireturn // returns tea.Model as required by bubbletea dispatch pattern +func (m rootModel) handleApplyResult(msg applyDiffResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + return m, func() tea.Msg { return errorFlashMsg{text: fmt.Sprintf("apply failed: %v", msg.err)} } + } + m.removeSuggestion(msg.index) + return m, func() tea.Msg { return errorFlashMsg{text: "Diff applied successfully"} } +} + +func (m *rootModel) removeSuggestion(index int) { + if index < len(m.improveTab.suggestions) { + m.improveTab.suggestions = append( + m.improveTab.suggestions[:index], + m.improveTab.suggestions[index+1:]..., + ) + if m.improveTab.selected >= len(m.improveTab.suggestions) && m.improveTab.selected > 0 { + m.improveTab.selected-- + } + } +} + +//nolint:ireturn // returns tea.Model as required by bubbletea dispatch pattern +func (m rootModel) delegateToActiveTab(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.screen != screenDashboard { + return m, nil + } + var cmd tea.Cmd + switch m.activeTab { + case tabStats: + m.statsTab, cmd = m.statsTab.update(msg) + case tabFriction: + m.frictionTab, cmd = m.frictionTab.update(msg) + case tabImprove: + m.improveTab, cmd = m.improveTab.update(msg) + } + return m, cmd +} + +func (m rootModel) View() string { + if m.err != nil { + return fmt.Sprintf("Error: %v\n\nCheck database paths and try again.", m.err) + } + + if m.screen == screenPicker && len(m.picker.skills) == 0 && m.picker.stats == nil { + return "Loading..." + } + + var b strings.Builder + + if m.screen == screenPicker { + b.WriteString(m.picker.view()) + b.WriteString("\n") + hints := "j/k navigate \u00b7 enter select \u00b7 q quit" + info := fmt.Sprintf("%d skills", len(m.picker.skills)) + b.WriteString(renderStatusBar(m.styles, hints, info, m.width)) + return b.String() + } + + // Dashboard screen + skillName := "" + if m.selectedSkill != nil { + skillName = m.selectedSkill.Name + } + + b.WriteString(renderTabBar(m.styles, m.activeTab, m.width, skillName)) + b.WriteString("\n") + + if m.showHelp { + b.WriteString(m.renderHelp()) + } else { + switch m.activeTab { + case tabStats: + b.WriteString(m.statsTab.view()) + case tabFriction: + b.WriteString(m.frictionTab.view()) + case tabImprove: + b.WriteString(m.improveTab.view()) + } + } + + // Status bar + b.WriteString("\n") + if m.errFlash != "" { + b.WriteString(m.styles.render(m.styles.errorFlash, m.errFlash)) + } else { + hints := m.activeTabHints() + info := m.activeTabInfo() + b.WriteString(renderStatusBar(m.styles, hints, info, m.width)) + } + + return b.String() +} + +func (m rootModel) contentHeight() int { + // Total height minus tab bar (2) and status bar (1) and newlines (2). + h := m.height - 5 + if h < 5 { + h = 5 + } + return h +} + +func (m rootModel) loadSkillDetail(skill skilldb.SkillRow) tea.Cmd { + return func() tea.Msg { + stats, err := m.skillDB.SkillStats(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return skillDetailLoadedMsg{err: fmt.Errorf("load skill stats: %w", err)} + } + + sessions, err := m.skillDB.RecentSessions(m.ctx, skill.Name, skill.SourceAgent, 10) + if err != nil { + return skillDetailLoadedMsg{err: fmt.Errorf("load recent sessions: %w", err)} + } + + friction, err := m.skillDB.SkillFrictionThemes(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return skillDetailLoadedMsg{err: fmt.Errorf("load friction themes: %w", err)} + } + + missing, err := m.skillDB.SkillMissingInstructions(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return skillDetailLoadedMsg{err: fmt.Errorf("load missing instructions: %w", err)} + } + + agents, err := m.skillDB.AgentBreakdown(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return skillDetailLoadedMsg{err: fmt.Errorf("load agent breakdown: %w", err)} + } + + improvements, err := m.skillDB.ListImprovements(m.ctx, skill.Name, skill.SourceAgent, "") + if err != nil { + return skillDetailLoadedMsg{err: fmt.Errorf("load improvements: %w", err)} + } + + return skillDetailLoadedMsg{ + stats: stats, + sessions: sessions, + friction: friction, + missing: missing, + agents: agents, + improvements: improvements, + } + } +} + +func (m rootModel) generateSuggestions() tea.Cmd { + return func() tea.Msg { + if m.selectedSkill == nil { + return generateDoneMsg{err: errors.New("no skill selected")} + } + + skill := *m.selectedSkill + + // Read skill file content. + skillPath := filepath.Join(m.repoRoot, skill.Path) + content, err := os.ReadFile(skillPath) //nolint:gosec // path is constructed from repo root + db path + if err != nil { + return generateDoneMsg{err: fmt.Errorf("read skill file: %w", err)} + } + + // Load data needed for the request. + stats, err := m.skillDB.SkillStats(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return generateDoneMsg{err: fmt.Errorf("load stats: %w", err)} + } + + friction, err := m.skillDB.SkillFrictionThemes(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return generateDoneMsg{err: fmt.Errorf("load friction: %w", err)} + } + + missing, err := m.skillDB.SkillMissingInstructions(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return generateDoneMsg{err: fmt.Errorf("load missing: %w", err)} + } + + agents, err := m.skillDB.AgentBreakdown(m.ctx, skill.Name, skill.SourceAgent) + if err != nil { + return generateDoneMsg{err: fmt.Errorf("load agents: %w", err)} + } + + // Build the request. + frictionRate := 0.0 + if stats.TotalSessions > 0 { + frictionRate = float64(stats.TotalFriction) / float64(stats.TotalSessions) * 100 + } + + var frictionThemes []skillimprove.FrictionTheme + for _, f := range friction { + frictionThemes = append(frictionThemes, skillimprove.FrictionTheme{ + Text: f.Text, + Category: f.Category, + Count: f.Count, + }) + } + + var missingInstructions []skillimprove.MissingInstruction + for _, mi := range missing { + missingInstructions = append(missingInstructions, skillimprove.MissingInstruction{ + Instruction: mi.Instruction, + Count: mi.Count, + Evidence: mi.Evidence, + }) + } + + var agentBreakdown []skillimprove.AgentStats + for _, a := range agents { + agentBreakdown = append(agentBreakdown, skillimprove.AgentStats{ + Agent: a.Agent, + SessionCount: a.SessionCount, + AvgScore: a.AvgScore, + }) + } + + req := skillimprove.SkillImprovementRequest{ + SkillName: skill.Name, + SkillPath: skill.Path, + SkillContent: string(content), + FrictionThemes: frictionThemes, + MissingInstructions: missingInstructions, + TotalSessions: stats.TotalSessions, + FrictionRate: frictionRate, + AgentBreakdown: agentBreakdown, + } + + result, err := m.generator.Generate(m.ctx, req) + if err != nil { + return generateDoneMsg{err: err} + } + + return generateDoneMsg{suggestions: result.Suggestions} + } +} + +func (m rootModel) applyDiff(index int) tea.Cmd { + return func() tea.Msg { + if index >= len(m.improveTab.suggestions) || m.selectedSkill == nil { + return applyDiffResultMsg{index: index, err: errors.New("invalid suggestion index")} + } + + sug := m.improveTab.suggestions[index] + skillPath := filepath.Join(m.repoRoot, m.selectedSkill.Path) + + if err := skillimprove.ApplyDiff(skillPath, sug.Diff); err != nil { + return applyDiffResultMsg{index: index, err: fmt.Errorf("apply diff: %w", err)} + } + + // Record the applied improvement in the database. + imp := skilldb.SkillImprovement{ + ID: fmt.Sprintf("%d-%s", time.Now().UnixNano(), m.selectedSkill.Name), + SkillName: m.selectedSkill.Name, + SourceAgent: m.selectedSkill.SourceAgent, + Title: sug.Title, + Description: sug.Description, + Diff: sug.Diff, + Priority: sug.Priority, + Status: "applied", + CreatedAt: time.Now().UTC(), + } + appliedAt := time.Now().UTC() + imp.AppliedAt = &appliedAt + + if err := m.skillDB.InsertImprovement(m.ctx, imp); err != nil { + // Non-fatal: diff was applied but recording failed. + return applyDiffResultMsg{index: index, err: nil} + } + + return applyDiffResultMsg{index: index, err: nil} + } +} + +func (m rootModel) activeTabHints() string { + switch m.activeTab { + case tabStats: + return "esc back \u00b7 r refresh \u00b7 q quit" + case tabFriction: + return "j/k scroll \u00b7 esc back \u00b7 q quit" + case tabImprove: + return "g generate \u00b7 a apply \u00b7 d dismiss \u00b7 j/k navigate \u00b7 esc back" + default: + return "q quit" + } +} + +func (m rootModel) activeTabInfo() string { + switch m.activeTab { + case tabStats: + if m.statsTab.stats != nil { + return fmt.Sprintf("%d sessions", m.statsTab.stats.TotalSessions) + } + case tabFriction: + total := len(m.frictionTab.friction) + len(m.frictionTab.missing) + return fmt.Sprintf("%d items", total) + case tabImprove: + return fmt.Sprintf("%d suggestions", len(m.improveTab.suggestions)) + } + return "" +} + +func (m rootModel) renderHelp() string { + var b strings.Builder + b.WriteString(m.styles.render(m.styles.bold, "Keyboard Shortcuts")) + b.WriteString("\n\n") + + b.WriteString(m.styles.render(m.styles.title, "Global")) + b.WriteString("\n") + b.WriteString(" Tab/Shift+Tab cycle tabs 1-3 jump to tab\n") + b.WriteString(" esc back q quit ? toggle help\n") + b.WriteString("\n") + + b.WriteString(m.styles.render(m.styles.title, "Stats")) + b.WriteString("\n") + b.WriteString(" r refresh data\n") + b.WriteString("\n") + + b.WriteString(m.styles.render(m.styles.title, "Friction & Gaps")) + b.WriteString("\n") + b.WriteString(" j/k scroll items\n") + b.WriteString("\n") + + b.WriteString(m.styles.render(m.styles.title, "Improve")) + b.WriteString("\n") + b.WriteString(" g generate suggestions a apply diff d dismiss\n") + b.WriteString(" j/k navigate suggestions\n") + + return b.String() +} diff --git a/cmd/entire/cli/skilltui/styles.go b/cmd/entire/cli/skilltui/styles.go new file mode 100644 index 000000000..a6cdef514 --- /dev/null +++ b/cmd/entire/cli/skilltui/styles.go @@ -0,0 +1,83 @@ +package skilltui + +import ( + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/termstyle" +) + +type tuiStyles struct { + colorEnabled bool + + // UI elements + title lipgloss.Style + selected lipgloss.Style + dim lipgloss.Style + bold lipgloss.Style + tabActive lipgloss.Style + tabInactive lipgloss.Style + statusBar lipgloss.Style + errorFlash lipgloss.Style + + // Tab bar & sections + appTitle lipgloss.Style + tabUnderline lipgloss.Style + sectionHeader lipgloss.Style + + // Skill-specific + priorityHigh lipgloss.Style + priorityMedium lipgloss.Style + priorityLow lipgloss.Style + diffAdd lipgloss.Style + diffRemove lipgloss.Style + success lipgloss.Style + friction lipgloss.Style + sparkline lipgloss.Style +} + +func newStyles() tuiStyles { + useColor := termstyle.ShouldUseColor(os.Stdout) + + s := tuiStyles{colorEnabled: useColor} + + if !useColor { + return s + } + + green := lipgloss.Color("2") + red := lipgloss.Color("1") + gray := lipgloss.Color("8") + amber := lipgloss.Color("214") + + s.title = lipgloss.NewStyle().Foreground(amber) + s.selected = lipgloss.NewStyle().Foreground(amber) + s.dim = lipgloss.NewStyle().Faint(true) + s.bold = lipgloss.NewStyle().Bold(true) + s.tabActive = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.tabInactive = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + s.statusBar = lipgloss.NewStyle().Faint(true) + s.errorFlash = lipgloss.NewStyle().Foreground(red) + + s.appTitle = lipgloss.NewStyle().Bold(true).Foreground(amber) + s.tabUnderline = lipgloss.NewStyle().Foreground(amber) + s.sectionHeader = lipgloss.NewStyle().Bold(true).Faint(true) + + s.priorityHigh = lipgloss.NewStyle().Foreground(red).Bold(true) + s.priorityMedium = lipgloss.NewStyle().Foreground(amber).Bold(true) + s.priorityLow = lipgloss.NewStyle().Foreground(gray) + s.diffAdd = lipgloss.NewStyle().Foreground(green) + s.diffRemove = lipgloss.NewStyle().Foreground(red) + s.success = lipgloss.NewStyle().Foreground(green) + s.friction = lipgloss.NewStyle().Foreground(red) + s.sparkline = lipgloss.NewStyle().Foreground(amber) + + return s +} + +func (s tuiStyles) render(style lipgloss.Style, text string) string { + if !s.colorEnabled { + return text + } + return style.Render(text) +} diff --git a/cmd/entire/cli/skilltui/tab_friction.go b/cmd/entire/cli/skilltui/tab_friction.go new file mode 100644 index 000000000..1a327ae5f --- /dev/null +++ b/cmd/entire/cli/skilltui/tab_friction.go @@ -0,0 +1,128 @@ +package skilltui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/entireio/cli/cmd/entire/cli/skilldb" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type frictionModel struct { + friction []skilldb.FrictionThemeRow + missing []skilldb.MissingInstructionRow + selected int + scroll int + styles tuiStyles + width int + height int +} + +func (m *frictionModel) setData(friction []skilldb.FrictionThemeRow, missing []skilldb.MissingInstructionRow) { + m.friction = friction + m.missing = missing + m.selected = 0 + m.scroll = 0 +} + +func (m *frictionModel) setSize(w, h int) { m.width = w; m.height = h } + +func (m frictionModel) totalItems() int { + return len(m.friction) + len(m.missing) +} + +//nolint:unparam // tea.Cmd return needed for bubbletea interface consistency +func (m frictionModel) update(msg tea.Msg) (frictionModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + total := m.totalItems() + switch { + case key.Matches(keyMsg, pickerKeyMap.Up): + if m.selected > 0 { + m.selected-- + } + case key.Matches(keyMsg, pickerKeyMap.Down): + if m.selected < total-1 { + m.selected++ + } + } + + return m, nil +} + +func (m frictionModel) view() string { + if len(m.friction) == 0 && len(m.missing) == 0 { + return " No friction or gaps recorded yet.\n" + } + + var b strings.Builder + itemIdx := 0 + + // Friction Themes section + if len(m.friction) > 0 { + b.WriteString(" ") + b.WriteString(m.styles.render(m.styles.sectionHeader, "Friction Themes")) + b.WriteString("\n\n") + + for _, f := range m.friction { + marker := " " + if itemIdx == m.selected { + marker = m.styles.render(m.styles.selected, "\u25b8 ") + } + + countStr := m.styles.render(m.styles.bold, fmt.Sprintf("[%dx]", f.Count)) + fmt.Fprintf(&b, "%s%s %s\n", marker, countStr, f.Text) + + if f.Category != "" { + fmt.Fprintf(&b, " Category: %s\n", f.Category) + } + if len(f.Sessions) > 0 { + sessStr := strings.Join(f.Sessions, ", ") + if len(sessStr) > 60 { + sessStr = sessStr[:57] + "..." + } + fmt.Fprintf(&b, " Sessions: %s\n", sessStr) + } + b.WriteString("\n") + itemIdx++ + } + } + + // Missing Instructions section + if len(m.missing) > 0 { + b.WriteString(" ") + b.WriteString(m.styles.render(m.styles.sectionHeader, "Missing Instructions")) + b.WriteString("\n\n") + + for _, mi := range m.missing { + marker := " " + if itemIdx == m.selected { + marker = m.styles.render(m.styles.selected, "\u25b8 ") + } + + countStr := m.styles.render(m.styles.bold, fmt.Sprintf("[%dx]", mi.Count)) + fmt.Fprintf(&b, "%s%s %s\n", marker, countStr, mi.Instruction) + + if len(mi.Evidence) > 0 { + for _, ev := range mi.Evidence { + if ev != "" { + evStr := ev + if len(evStr) > 70 { + evStr = evStr[:67] + "..." + } + fmt.Fprintf(&b, " Evidence: %q\n", evStr) + } + } + } + b.WriteString("\n") + itemIdx++ + } + } + + return b.String() +} diff --git a/cmd/entire/cli/skilltui/tab_improve.go b/cmd/entire/cli/skilltui/tab_improve.go new file mode 100644 index 000000000..9fd37ce42 --- /dev/null +++ b/cmd/entire/cli/skilltui/tab_improve.go @@ -0,0 +1,248 @@ +package skilltui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/entireio/cli/cmd/entire/cli/skilldb" + "github.com/entireio/cli/cmd/entire/cli/skillimprove" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type improveModel struct { + suggestions []skillimprove.SkillSuggestion + improvements []skilldb.SkillImprovement + selected int + isGenerating bool + spinner spinner.Model + styles tuiStyles + width int + height int +} + +func newImproveModel(styles tuiStyles) improveModel { + s := spinner.New() + s.Spinner = spinner.Dot + return improveModel{ + styles: styles, + spinner: s, + } +} + +func (m *improveModel) setData(improvements []skilldb.SkillImprovement) { + m.improvements = improvements +} + +func (m *improveModel) setSize(w, h int) { m.width = w; m.height = h } + +func (m improveModel) update(msg tea.Msg) (improveModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if m.isGenerating { + return m, nil + } + + switch { + case key.Matches(msg, pickerKeyMap.Up): + if m.selected > 0 { + m.selected-- + } + case key.Matches(msg, pickerKeyMap.Down): + if m.selected < len(m.suggestions)-1 { + m.selected++ + } + case key.Matches(msg, improveKeyMap.Generate): + return m, func() tea.Msg { return generateStartedMsg{} } + case key.Matches(msg, improveKeyMap.Apply): + if len(m.suggestions) > 0 && m.selected < len(m.suggestions) { + idx := m.selected + return m, func() tea.Msg { return applyDiffMsg{index: idx} } + } + case key.Matches(msg, improveKeyMap.Dismiss): + if len(m.suggestions) > 0 && m.selected < len(m.suggestions) { + idx := m.selected + return m, func() tea.Msg { return dismissSuggestionMsg{index: idx} } + } + } + + case spinner.TickMsg: + if m.isGenerating { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + } + + return m, nil +} + +func (m improveModel) view() string { + var b strings.Builder + + // Generating state + if m.isGenerating { + fmt.Fprintf(&b, " %s Analyzing skill usage data and generating suggestions...\n", + m.spinner.View()) + return b.String() + } + + // No suggestions state + if len(m.suggestions) == 0 { + b.WriteString(" No improvement suggestions yet.\n\n") + b.WriteString(" Press 'g' to analyze usage data and generate suggestions.\n") + + // Show history if available + if len(m.improvements) > 0 { + b.WriteString("\n") + b.WriteString(m.renderHistory()) + } + return b.String() + } + + // Suggestions list + fmt.Fprintf(&b, " %s\n", + m.styles.render(m.styles.sectionHeader, fmt.Sprintf("Suggestions (%d)", len(m.suggestions)))) + b.WriteString(" ") + b.WriteString(m.styles.render(m.styles.dim, strings.Repeat("\u2500", 40))) + b.WriteString("\n\n") + + for i, sug := range m.suggestions { + isSelected := i == m.selected + + marker := " " + if isSelected { + marker = m.styles.render(m.styles.selected, "\u25b8 ") + } + + priorityBadge := m.renderPriority(sug.Priority) + fmt.Fprintf(&b, "%s%s %s\n", marker, priorityBadge, sug.Title) + + // Show detail only for selected suggestion + if isSelected { + // Description + if sug.Description != "" { + wrapped := wrapText(sug.Description, m.width-6) + for _, line := range strings.Split(wrapped, "\n") { + fmt.Fprintf(&b, " %s\n", m.styles.render(m.styles.dim, line)) + } + b.WriteString("\n") + } + + // Diff + if sug.Diff != "" { + b.WriteString(m.renderDiff(sug.Diff)) + b.WriteString("\n") + } + + // Evidence + if len(sug.Evidence) > 0 { + fmt.Fprintf(&b, " Evidence: %d sessions\n", len(sug.Evidence)) + } + + fmt.Fprintf(&b, " %s\n", m.styles.render(m.styles.dim, "a: apply \u00b7 d: dismiss")) + } + b.WriteString("\n") + } + + // History section + if len(m.improvements) > 0 { + b.WriteString(m.renderHistory()) + } + + return b.String() +} + +func (m improveModel) renderPriority(priority string) string { + switch strings.ToLower(priority) { + case "high": + return m.styles.render(m.styles.priorityHigh, "[HIGH]") + case "medium": + return m.styles.render(m.styles.priorityMedium, "[MED]") + case "low": + return m.styles.render(m.styles.priorityLow, "[LOW]") + default: + return m.styles.render(m.styles.dim, fmt.Sprintf("[%s]", strings.ToUpper(priority))) + } +} + +func (m improveModel) renderDiff(diff string) string { + var b strings.Builder + for _, line := range strings.Split(diff, "\n") { + prefix := " " + switch { + case strings.HasPrefix(line, "+"): + b.WriteString(prefix + m.styles.render(m.styles.diffAdd, line) + "\n") + case strings.HasPrefix(line, "-"): + b.WriteString(prefix + m.styles.render(m.styles.diffRemove, line) + "\n") + case strings.HasPrefix(line, "@@"): + b.WriteString(prefix + m.styles.render(m.styles.dim, line) + "\n") + default: + b.WriteString(prefix + m.styles.render(m.styles.dim, line) + "\n") + } + } + return b.String() +} + +func (m improveModel) renderHistory() string { + var b strings.Builder + + applied := filterByStatus(m.improvements, "applied") + if len(applied) > 0 { + fmt.Fprintf(&b, " %s\n", + m.styles.render(m.styles.sectionHeader, fmt.Sprintf("Applied (%d)", len(applied)))) + b.WriteString(" ") + b.WriteString(m.styles.render(m.styles.dim, strings.Repeat("\u2500", 30))) + b.WriteString("\n") + + for _, imp := range applied { + ago := "unknown" + if imp.AppliedAt != nil { + ago = timeAgo(*imp.AppliedAt) + } + fmt.Fprintf(&b, " %s %-40s applied %s\n", + m.styles.render(m.styles.success, "\u2713"), + imp.Title, + m.styles.render(m.styles.dim, ago)) + } + } + + return b.String() +} + +func filterByStatus(improvements []skilldb.SkillImprovement, status string) []skilldb.SkillImprovement { + var result []skilldb.SkillImprovement + for _, imp := range improvements { + if imp.Status == status { + result = append(result, imp) + } + } + return result +} + +func wrapText(text string, maxWidth int) string { + if maxWidth <= 0 { + maxWidth = 80 + } + words := strings.Fields(text) + if len(words) == 0 { + return "" + } + + var lines []string + currentLine := words[0] + + for _, word := range words[1:] { + if len(currentLine)+1+len(word) > maxWidth { + lines = append(lines, currentLine) + currentLine = word + } else { + currentLine += " " + word + } + } + lines = append(lines, currentLine) + + return strings.Join(lines, "\n") +} diff --git a/cmd/entire/cli/skilltui/tab_stats.go b/cmd/entire/cli/skilltui/tab_stats.go new file mode 100644 index 000000000..177f2f4ef --- /dev/null +++ b/cmd/entire/cli/skilltui/tab_stats.go @@ -0,0 +1,177 @@ +package skilltui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/skilldb" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutation, value for update/view +type statsModel struct { + stats *skilldb.SkillStatsResult + sessions []skilldb.SkillSessionRow + agents []skilldb.AgentBreakdownRow + styles tuiStyles + width int + height int +} + +func (m *statsModel) setData(stats *skilldb.SkillStatsResult, sessions []skilldb.SkillSessionRow, agents []skilldb.AgentBreakdownRow) { + m.stats = stats + m.sessions = sessions + m.agents = agents +} + +func (m *statsModel) setSize(w, h int) { m.width = w; m.height = h } + +func (m statsModel) update(_ tea.Msg) (statsModel, tea.Cmd) { + return m, nil +} + +func (m statsModel) view() string { + if m.stats == nil { + return " Loading stats..." + } + + var b strings.Builder + cardWidth := m.width - 4 + if cardWidth < 40 { + cardWidth = 40 + } + + // Card 1: Usage Overview + b.WriteString(m.renderUsageOverview(cardWidth)) + b.WriteString("\n") + + // Card 2: Success Rate + b.WriteString(m.renderSuccessRate(cardWidth)) + b.WriteString("\n") + + // Card 3: Agent Breakdown + if len(m.agents) > 0 { + b.WriteString(m.renderAgentBreakdown(cardWidth)) + b.WriteString("\n") + } + + // Card 4: Recent Sessions + if len(m.sessions) > 0 { + b.WriteString(m.renderRecentSessions(cardWidth)) + } + + return b.String() +} + +func (m statsModel) renderUsageOverview(cardWidth int) string { + st := m.stats + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(cardWidth). + Padding(0, 1) + + var content strings.Builder + content.WriteString(m.styles.render(m.styles.sectionHeader, "Usage Overview")) + content.WriteString("\n") + fmt.Fprintf(&content, "Total Sessions: %-8d First Used: %-14s Avg Score: %.1f\n", + st.TotalSessions, formatDate(st.FirstUsed), st.AvgScore) + fmt.Fprintf(&content, "Total Tokens: %-8s Last Used: %-14s Frequency: %.1f/wk", + formatTokens64(st.TotalTokens), formatDate(st.LastUsed), st.SessionsPerWeek) + + return border.Render(content.String()) +} + +func (m statsModel) renderSuccessRate(cardWidth int) string { + st := m.stats + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(cardWidth). + Padding(0, 1) + + successCount := st.TotalSessions - st.TotalFriction + if successCount < 0 { + successCount = 0 + } + + var content strings.Builder + content.WriteString(m.styles.render(m.styles.sectionHeader, "Success Rate")) + content.WriteString("\n") + + barWidth := 25 + if st.TotalSessions > 0 { + successPct := float64(successCount) / float64(st.TotalSessions) * 100 + frictionPct := float64(st.TotalFriction) / float64(st.TotalSessions) * 100 + successFill := int(successPct / 100 * float64(barWidth)) + frictionFill := int(frictionPct / 100 * float64(barWidth)) + + successBar := m.styles.render(m.styles.success, strings.Repeat("\u2588", successFill)) + + strings.Repeat("\u2591", barWidth-successFill) + frictionBar := m.styles.render(m.styles.friction, strings.Repeat("\u2588", frictionFill)) + + strings.Repeat("\u2591", barWidth-frictionFill) + + fmt.Fprintf(&content, "Success %s %3.0f%% (%d sessions)\n", + successBar, successPct, successCount) + fmt.Fprintf(&content, "Friction %s %3.0f%% (%d sessions)", + frictionBar, frictionPct, st.TotalFriction) + } else { + content.WriteString("No session data available") + } + + return border.Render(content.String()) +} + +func (m statsModel) renderAgentBreakdown(cardWidth int) string { + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(cardWidth). + Padding(0, 1) + + var content strings.Builder + content.WriteString(m.styles.render(m.styles.sectionHeader, "Agent Breakdown")) + content.WriteString("\n") + fmt.Fprintf(&content, "%-16s %8s %9s %8s\n", + "Agent", "Sessions", "Avg Score", "Tokens") + + for _, a := range m.agents { + fmt.Fprintf(&content, "%-16s %8d %9.1f %8s\n", + a.Agent, a.SessionCount, a.AvgScore, formatTokens64(a.TotalTokens)) + } + + return border.Render(strings.TrimRight(content.String(), "\n")) +} + +func (m statsModel) renderRecentSessions(cardWidth int) string { + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(cardWidth). + Padding(0, 1) + + var content strings.Builder + content.WriteString(m.styles.render(m.styles.sectionHeader, "Recent Sessions")) + content.WriteString("\n") + + limit := 5 + if len(m.sessions) < limit { + limit = len(m.sessions) + } + + for _, sess := range m.sessions[:limit] { + outcomeStr := sess.Outcome + switch outcomeStr { + case "success": + outcomeStr = m.styles.render(m.styles.success, "success") + case "friction": + outcomeStr = m.styles.render(m.styles.friction, "friction") + } + + fmt.Fprintf(&content, "%s %-14s %-10s Score: %-4.0f Tokens: %s\n", + formatDate(sess.CreatedAt), + sess.Agent, + outcomeStr, + sess.OverallScore, + formatTokens(sess.TotalTokens)) + } + + return border.Render(strings.TrimRight(content.String(), "\n")) +} diff --git a/cmd/entire/cli/status_style.go b/cmd/entire/cli/status_style.go index fd5ef15b3..1582f83f9 100644 --- a/cmd/entire/cli/status_style.go +++ b/cmd/entire/cli/status_style.go @@ -1,25 +1,21 @@ package cli import ( - "fmt" "io" - "os" - "strconv" - "strings" "time" "github.com/charmbracelet/lipgloss" "github.com/entireio/cli/cmd/entire/cli/agent" - - "golang.org/x/term" + "github.com/entireio/cli/cmd/entire/cli/termstyle" ) // statusStyles holds pre-built lipgloss styles and terminal metadata. +// Fields are unexported to keep the rendering API internal to the cli package. +// All logic delegates to the termstyle package. type statusStyles struct { colorEnabled bool width int - // Styles green lipgloss.Style red lipgloss.Style gray lipgloss.Style @@ -29,116 +25,60 @@ type statusStyles struct { cyan lipgloss.Style } -// newStatusStyles creates styles appropriate for the output writer. +// newStatusStyles creates styles appropriate for the output writer by +// delegating to termstyle.New and mapping the exported fields to the +// unexported ones used throughout the cli package. func newStatusStyles(w io.Writer) statusStyles { - useColor := shouldUseColor(w) - width := getTerminalWidth(w) - - s := statusStyles{ - colorEnabled: useColor, - width: width, - } - - if useColor { - s.green = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - s.red = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - s.gray = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - s.bold = lipgloss.NewStyle().Bold(true) - s.dim = lipgloss.NewStyle().Faint(true) - s.agent = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) - s.cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + ts := termstyle.New(w) + return statusStyles{ + colorEnabled: ts.ColorEnabled, + width: ts.Width, + green: ts.Green, + red: ts.Red, + gray: ts.Gray, + bold: ts.Bold, + dim: ts.Dim, + agent: ts.Agent, + cyan: ts.Cyan, } - - return s } // render applies a style to text only when color is enabled. func (s statusStyles) render(style lipgloss.Style, text string) string { - if !s.colorEnabled { - return text - } - return style.Render(text) + return termstyle.Styles{ColorEnabled: s.colorEnabled}.Render(style, text) } // shouldUseColor returns true if the writer supports color output. func shouldUseColor(w io.Writer) bool { - if os.Getenv("NO_COLOR") != "" { - return false - } - if f, ok := w.(*os.File); ok { - return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd - } - return false + return termstyle.ShouldUseColor(w) } // getTerminalWidth returns the terminal width, capped at 80 with a fallback of 60. -// It first checks the writer itself, then falls back to Stdout/Stderr. func getTerminalWidth(w io.Writer) int { - // Try the output writer first - if f, ok := w.(*os.File); ok { - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd - return min(width, 80) - } - } - - // Fall back to Stdout, then Stderr - for _, f := range []*os.File{os.Stdout, os.Stderr} { - if f == nil { - continue - } - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd - return min(width, 80) - } - } - - return 60 + return termstyle.GetTerminalWidth(w) } // formatTokenCount formats a token count for display. // 0 → "0", 500 → "500", 1200 → "1.2k", 14300 → "14.3k" func formatTokenCount(n int) string { - if n < 1000 { - return strconv.Itoa(n) - } - f := float64(n) / 1000.0 - s := fmt.Sprintf("%.1f", f) - // Remove trailing ".0" for clean display (e.g., 1000 → "1k" not "1.0k") - s = strings.TrimSuffix(s, ".0") - return s + "k" + return termstyle.FormatTokenCount(n) } // totalTokens recursively sums all token fields including subagent tokens. func totalTokens(tu *agent.TokenUsage) int { - if tu == nil { - return 0 - } - total := tu.InputTokens + tu.CacheCreationTokens + tu.CacheReadTokens + tu.OutputTokens - total += totalTokens(tu.SubagentTokens) - return total + return termstyle.TotalTokens(tu) } // horizontalRule renders a dimmed horizontal rule of the given width. func (s statusStyles) horizontalRule(width int) string { - rule := strings.Repeat("─", width) - return s.render(s.dim, rule) + ts := termstyle.Styles{ColorEnabled: s.colorEnabled, Width: width, Dim: s.dim} + return ts.HorizontalRule() } // sectionRule renders a section header like: ── Active Sessions ──────────── func (s statusStyles) sectionRule(label string, width int) string { - prefix := "── " - content := label + " " - usedWidth := len([]rune(prefix)) + len([]rune(content)) - trailing := width - usedWidth - if trailing < 1 { - trailing = 1 - } - - var b strings.Builder - b.WriteString(s.render(s.dim, "── ")) - b.WriteString(s.render(s.dim, label)) - b.WriteString(" ") - b.WriteString(s.render(s.dim, strings.Repeat("─", trailing))) - return b.String() + ts := termstyle.Styles{ColorEnabled: s.colorEnabled, Width: width, Dim: s.dim} + return ts.SectionRule(label) } // activeTimeDisplay formats a last interaction time for display. diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 2e80e270e..3aef0271e 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" @@ -17,11 +18,13 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/types" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/insights" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/summarize" + "github.com/entireio/cli/cmd/entire/cli/termstyle" "github.com/entireio/cli/cmd/entire/cli/textutil" "github.com/entireio/cli/cmd/entire/cli/transcript" @@ -100,7 +103,7 @@ type condenseOpts struct { // // For mid-session commits (no Stop/SaveStep called yet), the shadow branch may not exist. // In this case, data is extracted from the live transcript instead. -func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID, state *SessionState, committedFiles map[string]struct{}, opts ...condenseOpts) (*CondenseResult, error) { +func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID, state *SessionState, committedFiles map[string]struct{}, opts ...condenseOpts) (*CondenseResult, error) { //nolint:maintidx // Condensation is inherently complex ag, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; callers use type assertions so nil is safe var o condenseOpts if len(opts) > 0 { @@ -243,6 +246,34 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re } } + // Compute session quality score (pure math, <1ms, no AI call) + var sessionScore *insights.SessionScore + if summary != nil { + data := insights.SessionData{ + TotalTokens: termstyle.TotalTokens(sessionData.TokenUsage), + FilesCount: len(sessionData.FilesTouched), + FrictionCount: len(summary.Friction), + TurnCount: turnCountFromState(state), + OpenItemCount: len(summary.OpenItems), + HasSummary: true, + } + breakdown := insights.ScoreSession(data) + overall := insights.ComputeOverall(breakdown) + sessionScore = &insights.SessionScore{ + CheckpointID: string(checkpointID), + SessionID: state.SessionID, + Agent: state.AgentType, + Model: state.ModelName, + CreatedAt: time.Now(), + Overall: overall, + Breakdown: breakdown, + TokensUsed: data.TotalTokens, + TurnCount: data.TurnCount, + FilesCount: data.FilesCount, + FrictionCount: data.FrictionCount, + } + } + // Build write options (shared by v1 and v2) writeOpts := cpkg.WriteCommittedOptions{ CheckpointID: checkpointID, @@ -256,6 +287,8 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re EphemeralBranch: shadowBranchName, AuthorName: authorName, AuthorEmail: authorEmail, + OwnerName: state.OwnerName, + OwnerEmail: state.OwnerEmail, Agent: state.AgentType, Model: state.ModelName, TurnID: state.TurnID, @@ -282,6 +315,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re Prompts: sessionData.Prompts, TotalTranscriptLines: sessionData.FullTranscriptLines, Transcript: sessionData.Transcript, + SessionScore: sessionScore, }, nil } @@ -299,6 +333,14 @@ func buildSessionMetrics(state *SessionState) *cpkg.SessionMetrics { } } +// turnCountFromState extracts the turn count from session state. +func turnCountFromState(state *SessionState) int { + if state.SessionTurnCount > 0 { + return state.SessionTurnCount + } + return state.StepCount // fallback to checkpoint count +} + func hasTokenUsageData(usage *agent.TokenUsage) bool { if usage == nil { return false diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index 0274958db..391d7e329 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -337,3 +337,30 @@ func TestCalculateTokenUsage_DroidStartOffsetBeyondEnd(t *testing.T) { t.Errorf("APICallCount = %d, want 0", usage.APICallCount) } } + +// TotalTokens tests are in termstyle/termstyle_test.go — the strategy package +// delegates to termstyle.TotalTokens. + +func TestTurnCountFromState_UsesTurnCount(t *testing.T) { + t.Parallel() + + state := &SessionState{ + SessionTurnCount: 7, + StepCount: 3, + } + if got := turnCountFromState(state); got != 7 { + t.Errorf("turnCountFromState(SessionTurnCount=7) = %d, want 7", got) + } +} + +func TestTurnCountFromState_FallsBackToStepCount(t *testing.T) { + t.Parallel() + + state := &SessionState{ + SessionTurnCount: 0, + StepCount: 5, + } + if got := turnCountFromState(state); got != 5 { + t.Errorf("turnCountFromState(SessionTurnCount=0, StepCount=5) = %d, want 5", got) + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index 9658bc499..7a5068092 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -223,6 +223,8 @@ func (s *ManualCommitStrategy) initializeSession(ctx context.Context, repo *git. return nil, fmt.Errorf("failed to generate turn ID: %w", err) } + ownerName, ownerEmail := GetGitAuthorFromRepo(repo) + now := time.Now() headHash := head.Hash().String() state := &SessionState{ @@ -239,6 +241,8 @@ func (s *ManualCommitStrategy) initializeSession(ctx context.Context, repo *git. UntrackedFilesAtStart: untrackedFiles, AgentType: agentType, ModelName: model, + OwnerName: ownerName, + OwnerEmail: ownerEmail, TranscriptPath: transcriptPath, LastPrompt: truncatePromptForStorage(userPrompt), } diff --git a/cmd/entire/cli/strategy/manual_commit_types.go b/cmd/entire/cli/strategy/manual_commit_types.go index 65d490157..0c51035a1 100644 --- a/cmd/entire/cli/strategy/manual_commit_types.go +++ b/cmd/entire/cli/strategy/manual_commit_types.go @@ -6,6 +6,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/insights" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/stringutil" ) @@ -52,9 +53,10 @@ type CondenseResult struct { SessionID string CheckpointsCount int FilesTouched []string - Prompts []string // User prompts from the condensed session - TotalTranscriptLines int // Total lines in transcript after this condensation - Transcript []byte // Raw transcript bytes for downstream consumers (trail title generation) + Prompts []string // User prompts from the condensed session + TotalTranscriptLines int // Total lines in transcript after this condensation + Transcript []byte // Raw transcript bytes for downstream consumers (trail title generation) + SessionScore *insights.SessionScore // Quality score (nil if scoring was skipped) } // ExtractedSessionData contains data extracted from a shadow branch. diff --git a/cmd/entire/cli/strategy/phase_wiring_test.go b/cmd/entire/cli/strategy/phase_wiring_test.go index 4f5030c01..9547efc2c 100644 --- a/cmd/entire/cli/strategy/phase_wiring_test.go +++ b/cmd/entire/cli/strategy/phase_wiring_test.go @@ -241,6 +241,23 @@ func TestInitializeSession_SetsModelName(t *testing.T) { "InitializeSession should set ModelName from model parameter") } +func TestInitializeSession_SetsOwnerFromGitConfig(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + s := &ManualCommitStrategy{} + + err := s.InitializeSession(context.Background(), "test-session-owner", "OpenCode", "", "", "") + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), "test-session-owner") + require.NoError(t, err) + require.NotNil(t, state) + + assert.Equal(t, "Test User", state.OwnerName) + assert.Equal(t, "test@test.com", state.OwnerEmail) +} + // TestInitializeSession_UpdatesModelOnSubsequentTurn verifies that model // is updated when the user switches models between turns. func TestInitializeSession_UpdatesModelOnSubsequentTurn(t *testing.T) { diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 7b2e6dd3e..083840bf5 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -1,16 +1,13 @@ package summarize import ( - "bytes" "context" "encoding/json" - "errors" "fmt" - "os" "os/exec" - "strings" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/llmcli" ) // summarizationPromptTemplate is the prompt used to generate summaries via the Claude CLI. @@ -47,9 +44,8 @@ Guidelines: - Return ONLY the JSON object, no markdown formatting or explanation` // DefaultModel is the default model used for summarization. -// Sonnet provides a good balance of quality and cost, with 1M context window -// to handle long transcripts without truncation. -const DefaultModel = "sonnet" +// Delegates to llmcli.DefaultModel for a single source of truth. +const DefaultModel = llmcli.DefaultModel // ClaudeGenerator generates summaries using the Claude CLI. type ClaudeGenerator struct { @@ -66,86 +62,22 @@ type ClaudeGenerator struct { CommandRunner func(ctx context.Context, name string, args ...string) *exec.Cmd } -// claudeCLIResponse represents the JSON response from the Claude CLI. -type claudeCLIResponse struct { - Result string `json:"result"` -} - // Generate creates a summary from checkpoint data by calling the Claude CLI. func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoint.Summary, error) { - // Format the transcript for the prompt transcriptText := FormatCondensedTranscript(input) - - // Build the prompt prompt := buildSummarizationPrompt(transcriptText) - // Execute the Claude CLI - runner := g.CommandRunner - if runner == nil { - runner = exec.CommandContext - } - - claudePath := g.ClaudePath - if claudePath == "" { - claudePath = "claude" + runner := &llmcli.Runner{ + ClaudePath: g.ClaudePath, + Model: g.Model, + CommandRunner: g.CommandRunner, } - model := g.Model - if model == "" { - model = DefaultModel - } - - // Use empty --setting-sources to skip all settings (user, project, local). - // This avoids loading MCP servers, hooks, or other config that could interfere - // with a simple --print summarization call. - cmd := runner(ctx, claudePath, "--print", "--output-format", "json", "--model", model, "--setting-sources", "") - - // Fully isolate the subprocess from the user's git repo (ENT-242). - // Claude Code performs internal git operations (plugin cache, context gathering) - // that pollute the worktree index with phantom entries from its plugin cache. - // We must both change the working directory AND strip GIT_* env vars, because - // git hooks set GIT_DIR which lets Claude Code find the repo regardless of cwd. - // This also prevents recursive triggering of Entire's own git hooks. - cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) - - // Pass prompt via stdin - cmd.Stdin = strings.NewReader(prompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() + resultJSON, _, err := runner.Execute(ctx, prompt) if err != nil { - // Check if the command was not found - var execErr *exec.Error - if errors.As(err, &execErr) { - return nil, fmt.Errorf("claude CLI not found: %w", err) - } - - // Check for exit error - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil, fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - - return nil, fmt.Errorf("failed to run claude CLI: %w", err) - } - - // Parse the CLI response - var cliResponse claudeCLIResponse - if err := json.Unmarshal(stdout.Bytes(), &cliResponse); err != nil { - return nil, fmt.Errorf("failed to parse claude CLI response: %w", err) + return nil, fmt.Errorf("execute claude CLI: %w", err) } - // The result field contains the actual JSON summary - resultJSON := cliResponse.Result - - // Try to extract JSON if it's wrapped in markdown code blocks - resultJSON = extractJSONFromMarkdown(resultJSON) - - // Parse the summary from the result var summary checkpoint.Summary if err := json.Unmarshal([]byte(resultJSON), &summary); err != nil { return nil, fmt.Errorf("failed to parse summary JSON: %w (response: %s)", err, resultJSON) @@ -159,40 +91,14 @@ func buildSummarizationPrompt(transcriptText string) string { return fmt.Sprintf(summarizationPromptTemplate, transcriptText) } -// stripGitEnv returns a copy of env with all GIT_* variables removed. -// This prevents a subprocess from discovering or modifying the parent's git repo. +// stripGitEnv delegates to llmcli.StripGitEnv. +// Kept as a package-level alias so existing tests that call stripGitEnv directly continue to compile. func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } - } - return filtered + return llmcli.StripGitEnv(env) } -// extractJSONFromMarkdown attempts to extract JSON from markdown code blocks. -// If the input is not wrapped in code blocks, it returns the input unchanged. +// extractJSONFromMarkdown delegates to llmcli.ExtractJSONFromMarkdown. +// Kept as a package-level alias so existing tests that call extractJSONFromMarkdown directly continue to compile. func extractJSONFromMarkdown(s string) string { - s = strings.TrimSpace(s) - - // Check for ```json ... ``` blocks - if strings.HasPrefix(s, "```json") { - s = strings.TrimPrefix(s, "```json") - if idx := strings.LastIndex(s, "```"); idx != -1 { - s = s[:idx] - } - return strings.TrimSpace(s) - } - - // Check for ``` ... ``` blocks - if strings.HasPrefix(s, "```") { - s = strings.TrimPrefix(s, "```") - if idx := strings.LastIndex(s, "```"); idx != -1 { - s = s[:idx] - } - return strings.TrimSpace(s) - } - - return s + return llmcli.ExtractJSONFromMarkdown(s) } diff --git a/cmd/entire/cli/termstyle/termstyle.go b/cmd/entire/cli/termstyle/termstyle.go new file mode 100644 index 000000000..49028baa4 --- /dev/null +++ b/cmd/entire/cli/termstyle/termstyle.go @@ -0,0 +1,154 @@ +// Package termstyle provides shared terminal styling utilities for CLI output. +// It wraps lipgloss styles with color/width detection so callers don't need +// to handle terminal detection themselves. +package termstyle + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/agent" + "golang.org/x/term" +) + +// Styles holds pre-built lipgloss styles and terminal metadata. +// ColorEnabled and Width are exported so callers can read them, but +// mutation of individual style fields should be done via assignment to the +// whole Styles value (returned from New). +type Styles struct { + ColorEnabled bool + Width int + + Green lipgloss.Style + Red lipgloss.Style + Yellow lipgloss.Style + Gray lipgloss.Style + Bold lipgloss.Style + Dim lipgloss.Style + Agent lipgloss.Style // amber/orange for agent names + Cyan lipgloss.Style +} + +// New creates a Styles value appropriate for the given output writer. +// Color is disabled when the writer is not a terminal or when NO_COLOR is set. +// Width is capped at 80 with a fallback of 60 when no terminal size is available. +func New(w io.Writer) Styles { + useColor := ShouldUseColor(w) + width := GetTerminalWidth(w) + + s := Styles{ + ColorEnabled: useColor, + Width: width, + } + + if useColor { + s.Green = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + s.Red = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + s.Yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + s.Gray = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + s.Bold = lipgloss.NewStyle().Bold(true) + s.Dim = lipgloss.NewStyle().Faint(true) + s.Agent = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + s.Cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + } + + return s +} + +// Render applies the given style to text only when color is enabled. +// When color is disabled the text is returned unchanged so output stays +// machine-readable (e.g. in CI or when piped). +func (s Styles) Render(style lipgloss.Style, text string) string { + if !s.ColorEnabled { + return text + } + return style.Render(text) +} + +// ShouldUseColor returns true if the writer supports color output. +// Color is suppressed when the NO_COLOR environment variable is non-empty, +// or when the writer is not an *os.File connected to a terminal. +func ShouldUseColor(w io.Writer) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if f, ok := w.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd + } + return false +} + +// GetTerminalWidth returns the terminal width, capped at 80 with a fallback of 60. +// It first checks the writer itself, then falls back to Stdout/Stderr. +func GetTerminalWidth(w io.Writer) int { + if f, ok := w.(*os.File); ok { + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + return min(width, 80) + } + } + + for _, f := range []*os.File{os.Stdout, os.Stderr} { + if f == nil { + continue + } + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + return min(width, 80) + } + } + + return 60 +} + +// FormatTokenCount formats a token count for compact display. +// Values below 1000 are rendered as plain integers; larger values use a +// one-decimal-place "k" suffix with the trailing ".0" trimmed +// (e.g. 0→"0", 500→"500", 1000→"1k", 1200→"1.2k", 14300→"14.3k"). +func FormatTokenCount(n int) string { + if n < 1000 { + return strconv.Itoa(n) + } + f := float64(n) / 1000.0 + s := fmt.Sprintf("%.1f", f) + s = strings.TrimSuffix(s, ".0") + return s + "k" +} + +// TotalTokens recursively sums all token fields in a TokenUsage value, +// including any subagent tokens. Returns 0 for a nil pointer. +func TotalTokens(tu *agent.TokenUsage) int { + if tu == nil { + return 0 + } + total := tu.InputTokens + tu.CacheCreationTokens + tu.CacheReadTokens + tu.OutputTokens + total += TotalTokens(tu.SubagentTokens) + return total +} + +// HorizontalRule renders a dimmed horizontal rule spanning the stored width. +func (s Styles) HorizontalRule() string { + rule := strings.Repeat("─", s.Width) + return s.Render(s.Dim, rule) +} + +// SectionRule renders a section header of the form: ── Label ──────────── +// The trailing dashes fill the remaining width; trailing is at least 1. +func (s Styles) SectionRule(label string) string { + prefix := "── " + content := label + " " + usedWidth := len([]rune(prefix)) + len([]rune(content)) + trailing := s.Width - usedWidth + if trailing < 1 { + trailing = 1 + } + + var b strings.Builder + b.WriteString(s.Render(s.Dim, "── ")) + b.WriteString(s.Render(s.Dim, label)) + b.WriteString(" ") + b.WriteString(s.Render(s.Dim, strings.Repeat("─", trailing))) + return b.String() +} diff --git a/cmd/entire/cli/termstyle/termstyle_test.go b/cmd/entire/cli/termstyle/termstyle_test.go new file mode 100644 index 000000000..ea9e3ddd2 --- /dev/null +++ b/cmd/entire/cli/termstyle/termstyle_test.go @@ -0,0 +1,174 @@ +package termstyle_test + +import ( + "bytes" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/termstyle" +) + +// TestNew_NoColor verifies that New returns a Styles with ColorEnabled=false +// when the writer is not a terminal (e.g. bytes.Buffer). +func TestNew_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + if s.ColorEnabled { + t.Error("expected ColorEnabled=false for non-terminal writer") + } +} + +// TestNew_Width verifies that New returns a fallback width when no terminal is present. +func TestNew_Width(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + if s.Width != 60 { + t.Errorf("expected Width=60 fallback, got %d", s.Width) + } +} + +// TestShouldUseColor_NoColorEnv verifies that NO_COLOR env disables color. +func TestShouldUseColor_NoColorEnv(t *testing.T) { + t.Setenv("NO_COLOR", "1") + got := termstyle.ShouldUseColor(&bytes.Buffer{}) + if got { + t.Error("expected false when NO_COLOR is set") + } +} + +// TestShouldUseColor_NonTerminal verifies that a buffer writer returns false. +func TestShouldUseColor_NonTerminal(t *testing.T) { + t.Parallel() + got := termstyle.ShouldUseColor(&bytes.Buffer{}) + if got { + t.Error("expected false for non-terminal writer") + } +} + +// TestGetTerminalWidth_Fallback verifies the fallback width of 60. +func TestGetTerminalWidth_Fallback(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + got := termstyle.GetTerminalWidth(&buf) + if got != 60 { + t.Errorf("expected fallback width 60, got %d", got) + } +} + +// TestFormatTokenCount covers the token count formatting rules. +func TestFormatTokenCount(t *testing.T) { + t.Parallel() + tests := []struct { + input int + want string + }{ + {0, "0"}, + {1, "1"}, + {999, "999"}, + {1000, "1k"}, + {1200, "1.2k"}, + {14300, "14.3k"}, + {100000, "100k"}, + } + for _, tt := range tests { + got := termstyle.FormatTokenCount(tt.input) + if got != tt.want { + t.Errorf("FormatTokenCount(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// TestTotalTokens_Nil verifies nil input returns 0. +func TestTotalTokens_Nil(t *testing.T) { + t.Parallel() + if got := termstyle.TotalTokens(nil); got != 0 { + t.Errorf("TotalTokens(nil) = %d, want 0", got) + } +} + +// TestTotalTokens_Basic verifies basic token summation. +func TestTotalTokens_Basic(t *testing.T) { + t.Parallel() + tu := &agent.TokenUsage{ + InputTokens: 10, + CacheCreationTokens: 5, + CacheReadTokens: 3, + OutputTokens: 2, + } + want := 20 + if got := termstyle.TotalTokens(tu); got != want { + t.Errorf("TotalTokens = %d, want %d", got, want) + } +} + +// TestTotalTokens_Recursive verifies subagent tokens are included. +func TestTotalTokens_Recursive(t *testing.T) { + t.Parallel() + tu := &agent.TokenUsage{ + InputTokens: 10, + OutputTokens: 5, + SubagentTokens: &agent.TokenUsage{ + InputTokens: 3, + OutputTokens: 2, + }, + } + want := 20 // 10+5 + 3+2 + if got := termstyle.TotalTokens(tu); got != want { + t.Errorf("TotalTokens = %d, want %d", got, want) + } +} + +// TestRender_NoColor verifies Render returns plain text when color is disabled. +func TestRender_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + got := s.Render(s.Bold, "hello") + if got != "hello" { + t.Errorf("Render with no color = %q, want %q", got, "hello") + } +} + +// TestHorizontalRule_NoColor verifies HorizontalRule returns plain dashes when no color. +func TestHorizontalRule_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + // Override width for deterministic output + s.Width = 5 + got := s.HorizontalRule() + want := "─────" + if got != want { + t.Errorf("HorizontalRule() = %q, want %q", got, want) + } +} + +// TestSectionRule_NoColor verifies SectionRule output format without color. +func TestSectionRule_NoColor(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + s.Width = 20 + got := s.SectionRule("Foo") + // "── Foo " = 7 runes used, trailing = 13 + want := "── Foo " + "─────────────" + if got != want { + t.Errorf("SectionRule(%q) = %q, want %q", "Foo", got, want) + } +} + +// TestSectionRule_ShortWidth verifies trailing is at least 1 even when label is long. +func TestSectionRule_ShortWidth(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + s := termstyle.New(&buf) + s.Width = 1 + got := s.SectionRule("Very Long Label") + // trailing forced to 1 minimum + want := "── Very Long Label " + "─" + if got != want { + t.Errorf("SectionRule short width = %q, want %q", got, want) + } +} diff --git a/docs/plans/2026-03-26-memory-loop-heavyweight-design.md b/docs/plans/2026-03-26-memory-loop-heavyweight-design.md new file mode 100644 index 000000000..375ea08cf --- /dev/null +++ b/docs/plans/2026-03-26-memory-loop-heavyweight-design.md @@ -0,0 +1,364 @@ +# Heavyweight Memory Loop Design + +## Summary + +Turn the current snapshot-style memory loop into a durable learning system that supports: + +- layered memory for personal and shared repo knowledge +- explicit review and promotion workflows +- recommendation history and lifecycle tracking +- outcome-aware pruning instead of snapshot overwrite + +This design keeps `insights` and `improve` unchanged. The memory loop remains a separate subsystem that reads from existing session and facet data, but its local state becomes a unified memory store rather than a single active snapshot. + +## Goals + +- Reduce repeated developer babysitting across sessions. +- Preserve repo-specific and workflow-specific lessons over time. +- Support multi-contributor repos without letting one developer's generated memory silently affect everyone else. +- Make memory state inspectable, reviewable, suppressible, and pruneable. + +## Non-Goals + +- No changes to `entire insights` or `entire improve`. +- No new remote or shared backend. +- No attempt to infer perfect causality for whether a memory "worked". +- No requirement for interactive TUI review in v1. + +## Current State + +Today the memory loop: + +- builds one snapshot from recent cached sessions +- stores active memories in `.entire/memory-loop.json` +- injects top-ranked active memories into Claude turn start +- tracks recent injection logs + +This is useful as a retrieval experiment, but it has important gaps: + +- no candidate review stage +- no durable recommendation history +- no manual memory entry +- no suppression or archive workflow +- no distinction between personal and shared repo memory +- no outcome-based pruning + +## Design Options Considered + +### Option 1: Keep a Lightweight Snapshot and Bolt On Small Controls + +Pros: + +- smallest code change +- minimal migration cost + +Cons: + +- weak model for history and pruning +- awkward fit for multi-contributor governance +- would accumulate flags without a coherent lifecycle + +### Option 2: Unified Heavyweight Memory Store + +Pros: + +- one record model supports candidate, active, suppressed, and archived states +- history, review, pruning, and dedupe work against the same store +- simpler than separate current and history stores + +Cons: + +- larger schema change inside `.entire/memory-loop.json` +- more command surface + +### Option 3: Separate Current Store and History Store + +Pros: + +- conceptually separates live state from audit trail + +Cons: + +- more bookkeeping and sync risk +- every lifecycle transition becomes a cross-store move +- overbuilt for a PoC + +## Recommendation + +Implement **Option 2: a unified heavyweight memory store**. + +Each memory record remains in one local JSON file and changes lifecycle state over time. This preserves recommendation history without introducing a second storage system. + +## Storage Model + +Use one local file at `.entire/memory-loop.json` with a store shape like: + +- `mode`: `off|manual|auto` +- `activation_policy`: `review|auto` +- `generated_at` +- `scope` +- `scope_value` +- `records` +- `injection_logs` +- `refresh_history` + +Each memory record contains: + +- identity: `id`, `fingerprint` +- classification: `kind`, `scope_kind`, `scope_value`, `origin` +- content: `title`, `body`, `why`, `evidence` +- provenance: `source_session_ids`, `owner_email` +- lifecycle: `status` +- scoring: `confidence`, `strength` +- activity: `inject_count`, `match_count`, `last_injected_at`, `last_matched_at` +- review: `created_at`, `updated_at`, `last_reviewed_at` +- outcome: `outcome` +- audit trail: `history` + +### Unified File Model + +The same record moves through statuses instead of being copied between separate stores: + +- `candidate` +- `active` +- `suppressed` +- `archived` + +This means: + +- `show` can group by status without merging multiple stores +- `refresh` can dedupe against accepted, suppressed, and archived history +- `prune` changes statuses instead of moving records around + +## Lifecycle + +### Statuses + +- `candidate` + Generated and pending review. Never injects. +- `active` + Eligible for ranking and injection. +- `suppressed` + Explicitly rejected. Never injects and strongly resists regeneration. +- `archived` + Historical only. Not active, but kept for history and dedupe. + +### State Transitions + +- generated memory becomes `candidate` or `active` depending on activation policy and scope rules +- `activate` promotes a `candidate` to `active` +- `suppress` moves a `candidate` or `active` memory to `suppressed` +- `unsuppress` moves a `suppressed` memory back to `candidate` +- `archive` retires a memory from active circulation +- `prune` archives or demotes stale/ineffective records + +## Personal and Repo Layers + +The system should support layered memory: + +- personal memory +- shared repo memory + +Each record carries scope: + +- `scope_kind = me|repo` +- `scope_value` + +Branch scope remains useful as a refresh filter, but the long-term retrieval model should be personal plus repo layers. + +### Defaults + +- manual add defaults to personal scope +- generated personal memories follow normal activation policy +- repo-scoped generated memories never become shared-active automatically +- repo-scoped manual memories can be active immediately because they are already explicit user intent + +## Governance for Shared Repo Memory + +For multi-contributor repos, shared repo memory uses explicit promotion. + +### Approved Governance Model + +- repo-scoped generated memories remain `candidate` +- they do not inject until explicitly promoted +- only shared repo `active` records can affect everyone + +This avoids one developer's noisy generated memory silently steering other contributors' prompts. + +## Modes and Policies + +### Injection Mode + +- `off` + Memory loop is inert for injection. +- `manual` + No automatic injection; preview commands still work. +- `auto` + Relevant active memories inject at Claude turn start. + +### Activation Policy + +- `review` + Generated memories remain pending review. +- `auto` + Eligible generated memories can become active automatically. + +These are separate controls: + +- mode answers "should active memories inject?" +- activation policy answers "what happens to newly generated memories?" + +## Refresh Flow + +`entire memory-loop refresh` should: + +1. print step-based progress +2. refresh the insights cache if needed +3. backfill summaries and facets if needed +4. load scoped sessions +5. generate new memory candidates +6. dedupe them against existing history using fingerprints +7. apply activation policy and scope rules +8. save the updated memory store +9. print a summary of active and pending candidates + +### Progress Output + +Use explicit progress steps, not a spinner: + +- `Refreshing cache...` +- `Backfilling summaries...` +- `Backfilling facets...` +- `Loading scoped sessions...` +- `Distilling memories...` +- `Reconciling with existing memory history...` +- `Saving memory store...` + +## Command Surface + +The heavyweight command surface should include: + +- `entire memory-loop mode off|manual|auto` +- `entire memory-loop policy review|auto` +- `entire memory-loop refresh [--last N] [--scope ...] [--review] [--auto-activate]` +- `entire memory-loop show` +- `entire memory-loop status [--prompt "..."] [--verbose]` +- `entire memory-loop add --kind ... --title ... --body ... [--scope me|repo]` +- `entire memory-loop activate ` +- `entire memory-loop promote ` +- `entire memory-loop suppress ` +- `entire memory-loop unsuppress ` +- `entire memory-loop archive ` +- `entire memory-loop prune` + +### Command Semantics + +- `activate` + Promotes a personal or local candidate to active. +- `promote` + Explicitly promotes a repo candidate into shared repo-active state. +- `suppress` + Rejects a memory and discourages regeneration. +- `unsuppress` + Returns a suppressed memory to candidate state. +- `archive` + Retires a memory while preserving history. + +## Retrieval + +Retrieval should only consider `active` records. + +Default retrieval composition: + +- personal active records +- shared repo active records + +Personal records should outrank repo records when scores are otherwise similar. + +### Manual Visibility + +`status --prompt` should preview: + +- ranked memory matches +- score +- reason +- whether a memory came from the personal or repo layer + +`--verbose` should reveal score components and matched evidence. + +## Outcome Tracking + +The memory loop does not need perfect causality, but it should track enough to support pruning. + +### Record Activity Fields + +- `inject_count` +- `match_count` +- `last_injected_at` +- `last_matched_at` + +### Injection Logs + +Each log entry stores: + +- session id +- prompt preview +- injected memory ids +- timestamp +- reason + +### Derived Outcome + +Use a simple derived field: + +- `neutral` +- `reinforced` +- `ineffective` + +This should be updated during refresh using later sessions and their facets: + +- repeated supporting evidence reinforces a memory +- recurring matching friction after repeated injection marks it ineffective + +## Pruning Policy + +Recommended defaults: + +- archive stale candidates after 30 days +- archive active memories with no matches after 60 days +- demote or archive active memories marked ineffective after repeated injections +- retain suppressed memories longer than others to preserve rejection history +- never auto-prune manual memories unless explicitly archived + +Suppression history should remain effective even if a record is archived later. Fingerprints should continue to block easy regeneration of the same rejected idea. + +## Multi-Contributor Behavior + +In shared repos: + +- personal memories are private to the developer's local store +- shared repo memories are explicit shared knowledge +- generated repo candidates do not become shared-active automatically + +This allows: + +- local experimentation with personal memory +- shared institutional memory for real repo rules +- safer governance for team environments + +## Manual Verification + +This PoC does not prioritize automated tests. Verification should focus on manual end-to-end checks: + +1. refresh with personal and repo scopes +2. inspect pending and active records +3. manually add, activate, suppress, promote, archive, and prune +4. preview retrieval with `status --prompt` +5. confirm only active records inject in `auto` mode +6. confirm repo candidates do not inject before promotion + +## Open Follow-Ups + +- whether to eventually sync shared repo-active memory across collaborators rather than keeping it purely local +- whether repo-level promotion should later require stronger governance +- whether a future migration from JSON to SQLite is warranted after the workflow proves itself diff --git a/docs/plans/2026-03-26-memory-loop-heavyweight.md b/docs/plans/2026-03-26-memory-loop-heavyweight.md new file mode 100644 index 000000000..3cb1b9e7c --- /dev/null +++ b/docs/plans/2026-03-26-memory-loop-heavyweight.md @@ -0,0 +1,467 @@ +# Heavyweight Memory Loop Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Turn the current memory-loop snapshot into a heavyweight memory manager with lifecycle states, layered personal and repo memory, explicit promotion for shared memory, history, pruning, and better operator controls. + +**Architecture:** Extend the existing `memoryloop` JSON state into a unified memory store rather than a single snapshot. Keep generation and Claude injection in the current command and lifecycle flow, but add lifecycle-aware record reconciliation, richer commands, and outcome metadata so the store can support candidate review, manual memories, shared repo governance, and pruning. + +**Tech Stack:** Go 1.26, Cobra CLI, local JSON state in `.entire/memory-loop.json`, existing insights cache and Claude hook injection path. + +--- + +### Task 1: Expand the memory store model + +**Files:** +- Modify: `cmd/entire/cli/memoryloop/memoryloop.go` +- Test: `cmd/entire/cli/memoryloop/memoryloop_test.go` + +**Step 1: Add the new enums and state types** + +Add lifecycle and scope types to `cmd/entire/cli/memoryloop/memoryloop.go`: + +```go +type Mode string + +const ( + ModeOff Mode = "off" + ModeManual Mode = "manual" + ModeAuto Mode = "auto" +) + +type ActivationPolicy string + +const ( + ActivationPolicyReview ActivationPolicy = "review" + ActivationPolicyAuto ActivationPolicy = "auto" +) + +type Status string + +const ( + StatusCandidate Status = "candidate" + StatusActive Status = "active" + StatusSuppressed Status = "suppressed" + StatusArchived Status = "archived" +) +``` + +**Step 2: Expand record and store fields** + +Add store and record fields for: + +- `Mode` +- `ActivationPolicy` +- `RefreshHistory` +- `Fingerprint` +- `ScopeKind` +- `ScopeValue` +- `Origin` +- `OwnerEmail` +- `LastReviewedAt` +- `LastInjectedAt` +- `LastMatchedAt` +- `InjectCount` +- `MatchCount` +- `Outcome` +- `History` + +**Step 3: Preserve backward compatibility** + +Update `normalizeState(...)` so older snapshot-only files still load with sane defaults: + +- `mode=auto` if existing snapshot had injection enabled +- `activation_policy=review` +- missing records default to `active` + +**Step 4: Run focused tests** + +Run: `go test ./cmd/entire/cli/memoryloop -run 'Test.*State|Test.*Select|Test.*Format'` + +Expected: existing state loading still works and new defaults normalize correctly. + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/memoryloop/memoryloop.go cmd/entire/cli/memoryloop/memoryloop_test.go +git commit -m "feat: expand memory-loop state model" +``` + +### Task 2: Rework generation into reconcile-plus-history + +**Files:** +- Modify: `cmd/entire/cli/memoryloop/generator.go` +- Modify: `cmd/entire/cli/memoryloop/memoryloop.go` +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Test: `cmd/entire/cli/memoryloop/memoryloop_test.go` +- Test: `cmd/entire/cli/memory_loop_cmd_test.go` + +**Step 1: Stop generating only active records** + +Change generator output mapping so generated records are raw candidates with: + +- stable `fingerprint` +- `origin=generated` +- `status` decided later during reconciliation + +**Step 2: Add reconciliation helpers** + +Implement helpers like: + +```go +func ReconcileGeneratedRecords(existing []MemoryRecord, generated []MemoryRecord, mode Mode, policy ActivationPolicy) []MemoryRecord +func FindByFingerprint(records []MemoryRecord, fingerprint string) *MemoryRecord +``` + +Rules: + +- dedupe against all statuses +- do not resurrect suppressed fingerprints as active +- keep archived history +- for repo-scoped generated records, keep as `candidate` +- for personal generated records, apply `review|auto` + +**Step 3: Add refresh history entries** + +Record: + +- refresh time +- source window +- scope +- number generated +- number activated +- number left as candidate + +**Step 4: Run focused tests** + +Run: `go test ./cmd/entire/cli/... -run 'Test.*MemoryLoop.*Refresh|Test.*Reconcile'` + +Expected: new generated records reconcile correctly against existing active, suppressed, and archived memory. + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/memoryloop/generator.go cmd/entire/cli/memoryloop/memoryloop.go cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/memoryloop/memoryloop_test.go cmd/entire/cli/memory_loop_cmd_test.go +git commit -m "feat: reconcile generated memories with history" +``` + +### Task 3: Add mode and activation policy controls + +**Files:** +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Modify: `cmd/entire/cli/settings/settings.go` +- Test: `cmd/entire/cli/memory_loop_settings_test.go` +- Test: `cmd/entire/cli/memory_loop_cmd_test.go` + +**Step 1: Replace enable/disable with mode** + +Add: + +- `entire memory-loop mode off|manual|auto` +- `entire memory-loop policy review|auto` + +Remove the old binary mental model from command output even if you keep compatibility wrappers. + +**Step 2: Wire defaults from settings/state** + +Persist: + +- mode +- activation policy +- max injected + +Keep state authoritative once the memory store exists. + +**Step 3: Run focused tests** + +Run: `go test ./cmd/entire/cli/... -run 'Test.*Mode|Test.*Policy'` + +Expected: commands update stored state and status output reflects the new controls. + +**Step 4: Commit** + +```bash +git add cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/settings/settings.go cmd/entire/cli/memory_loop_settings_test.go cmd/entire/cli/memory_loop_cmd_test.go +git commit -m "feat: add memory-loop mode and activation policy" +``` + +### Task 4: Add progress output and richer refresh summaries + +**Files:** +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Test: `cmd/entire/cli/memory_loop_cmd_test.go` + +**Step 1: Print explicit refresh progress** + +Emit lines before each major step: + +```text +Refreshing cache... +Backfilling summaries... +Backfilling facets... +Loading scoped sessions... +Distilling memories... +Reconciling with existing memory history... +Saving memory store... +``` + +**Step 2: Print counts by lifecycle** + +At refresh completion, print: + +- active count +- candidate count +- suppressed count +- archived count +- generated and activated counts for this refresh + +**Step 3: Run focused tests** + +Run: `go test ./cmd/entire/cli/... -run 'Test.*Refresh.*Output'` + +Expected: progress steps and final counts render predictably. + +**Step 4: Commit** + +```bash +git add cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/memory_loop_cmd_test.go +git commit -m "feat: add memory-loop refresh progress output" +``` + +### Task 5: Add lifecycle management commands + +**Files:** +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Modify: `cmd/entire/cli/memoryloop/memoryloop.go` +- Test: `cmd/entire/cli/memory_loop_cmd_test.go` +- Test: `cmd/entire/cli/memoryloop/memoryloop_test.go` + +**Step 1: Add explicit commands** + +Implement: + +- `activate ` +- `promote ` +- `suppress ` +- `unsuppress ` +- `archive ` + +**Step 2: Encode governance rules** + +Rules: + +- `activate` should not turn a repo candidate into shared-active +- `promote` is required for repo-scoped generated candidates +- `unsuppress` returns to `candidate` +- `archive` preserves history + +**Step 3: Record history events** + +Append record history entries on each lifecycle transition. + +**Step 4: Run focused tests** + +Run: `go test ./cmd/entire/cli/... -run 'Test.*Activate|Test.*Promote|Test.*Suppress|Test.*Archive'` + +Expected: lifecycle transitions behave correctly for personal and repo-scoped records. + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/memoryloop/memoryloop.go cmd/entire/cli/memory_loop_cmd_test.go cmd/entire/cli/memoryloop/memoryloop_test.go +git commit -m "feat: add memory-loop lifecycle commands" +``` + +### Task 6: Add manual memory entry + +**Files:** +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Modify: `cmd/entire/cli/memoryloop/memoryloop.go` +- Test: `cmd/entire/cli/memory_loop_cmd_test.go` +- Test: `cmd/entire/cli/memoryloop/memoryloop_test.go` + +**Step 1: Implement `add`** + +Add: + +```text +entire memory-loop add --kind repo_rule --title "..." --body "..." --scope me|repo +``` + +Defaults: + +- `scope=me` +- `origin=manual` +- `status=active` + +**Step 2: Validate scope and IDs** + +Ensure: + +- repo-scoped manual adds are explicit +- fingerprints dedupe against existing records +- manual records are clearly marked for pruning exemptions + +**Step 3: Run focused tests** + +Run: `go test ./cmd/entire/cli/... -run 'Test.*Add'` + +Expected: manual memories become active immediately and preserve scope and origin metadata. + +**Step 4: Commit** + +```bash +git add cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/memoryloop/memoryloop.go cmd/entire/cli/memory_loop_cmd_test.go cmd/entire/cli/memoryloop/memoryloop_test.go +git commit -m "feat: add manual memory entries" +``` + +### Task 7: Improve status and show output for review and retrieval visibility + +**Files:** +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Modify: `cmd/entire/cli/memoryloop/memoryloop.go` +- Test: `cmd/entire/cli/memory_loop_cmd_test.go` + +**Step 1: Separate `status` and `show` responsibilities** + +- `status` + - mode + - activation policy + - scope + - counts by status + - last refresh + - optional prompt preview +- `show` + - grouped detailed inventory + - recent refresh history + - recent injection logs + +**Step 2: Add verbose retrieval preview** + +For `status --prompt --verbose`, show: + +- memory id +- score +- reason +- scope +- status + +**Step 3: Run focused tests** + +Run: `go test ./cmd/entire/cli/... -run 'Test.*Show|Test.*Status|Test.*PromptPreview'` + +Expected: status is concise, show is detailed, and prompt preview includes reasons. + +**Step 4: Commit** + +```bash +git add cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/memoryloop/memoryloop.go cmd/entire/cli/memory_loop_cmd_test.go +git commit -m "feat: improve memory-loop inspection output" +``` + +### Task 8: Add outcome tracking and pruning + +**Files:** +- Modify: `cmd/entire/cli/lifecycle.go` +- Modify: `cmd/entire/cli/memoryloop/memoryloop.go` +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Test: `cmd/entire/cli/lifecycle_test.go` +- Test: `cmd/entire/cli/memory_loop_cmd_test.go` +- Test: `cmd/entire/cli/memoryloop/memoryloop_test.go` + +**Step 1: Record match and injection activity** + +Update retrieval and injection paths to persist: + +- `match_count` +- `last_matched_at` +- `inject_count` +- `last_injected_at` + +**Step 2: Derive simple outcomes during refresh** + +Use recent sessions and facets to mark records as: + +- `neutral` +- `reinforced` +- `ineffective` + +**Step 3: Add `prune` command** + +Apply default rules: + +- archive stale candidates after 30 days +- archive active memories with zero matches after 60 days +- demote or archive ineffective active memories after repeated injection +- never auto-prune manual memories + +**Step 4: Run focused tests** + +Run: `go test ./cmd/entire/cli/... -run 'Test.*Prune|Test.*Injection|Test.*Outcome'` + +Expected: activity metadata updates during injection and pruning changes only eligible generated records. + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/lifecycle.go cmd/entire/cli/memoryloop/memoryloop.go cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/lifecycle_test.go cmd/entire/cli/memory_loop_cmd_test.go cmd/entire/cli/memoryloop/memoryloop_test.go +git commit -m "feat: add memory-loop outcomes and pruning" +``` + +### Task 9: Manual verification pass + +**Files:** +- Modify: `cmd/entire/cli/memory_loop_cmd.go` +- Modify: `cmd/entire/cli/memoryloop/memoryloop.go` +- Modify: `cmd/entire/cli/lifecycle.go` + +**Step 1: Run formatter** + +Run: `mise run fmt` + +Expected: no formatting diffs remain. + +**Step 2: Run targeted package tests if present** + +Run: `go test ./cmd/entire/cli/...` + +Expected: passing, if tests were kept current during implementation. + +**Step 3: Run lint** + +Run: `mise run lint` + +Expected: no lint findings. + +**Step 4: Manual operator flow** + +Run: + +```bash +entire memory-loop refresh --last 20 --scope me +entire memory-loop show +entire memory-loop status --prompt "fix lint in capabilities.go" --verbose +entire memory-loop add --kind repo_rule --title "Run lint before final answer" --body "Run lint before concluding changes." --scope me +entire memory-loop prune +``` + +Expected: + +- progress output appears +- candidate and active records are separated +- prompt preview shows reasons and scores +- manual memory is active immediately +- prune only affects eligible generated records + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/memory_loop_cmd.go cmd/entire/cli/memoryloop/memoryloop.go cmd/entire/cli/lifecycle.go +git commit -m "chore: verify heavyweight memory-loop workflow" +``` + +## Notes + +- The user explicitly said this is a PoC and not to prioritize writing tests. Keep manual verification as the main acceptance path. +- If lightweight command tests already exist, update only the ones directly touched instead of adding broad new coverage. +- Do not change `insights` or `improve` behavior while implementing this plan. diff --git a/docs/plans/2026-03-26-memory-loop-tui-redesign-design.md b/docs/plans/2026-03-26-memory-loop-tui-redesign-design.md new file mode 100644 index 000000000..4fc548a6c --- /dev/null +++ b/docs/plans/2026-03-26-memory-loop-tui-redesign-design.md @@ -0,0 +1,260 @@ +# Memory Loop TUI Redesign Design + +## Summary + +Redesign the memory-loop TUI so it feels like an intentional terminal application instead of a set of lightly styled tables. Build two usable layouts on top of the existing memory-loop state and actions: + +- `Inspector`: a keyboard-first operational view for reviewing and acting on individual memories +- `Dashboard`: a higher-level overview for scanning memory health, recent activity, and system status + +Both layouts should share the same data model, commands, lifecycle actions, and styling primitives so the user can compare them in actual use and decide which direction to keep. + +## Goals + +- Make the TUI visually coherent and easier to scan. +- Preserve fast keyboard-driven workflows for reviewing memories. +- Let the user compare `Inspector` and `Dashboard` layouts in the same command. +- Surface the most important memory-loop information without forcing tab-hopping. +- Improve hierarchy, spacing, and panel structure without changing memory-loop semantics. + +## Non-Goals + +- No change to memory-loop storage or record lifecycle semantics. +- No rewrite of refresh, ranking, or injection logic. +- No new remote state or shared backend. +- No irreversible decision about final layout in this change; the user will compare both. + +## Current Problems + +The current TUI works, but the presentation is weak: + +- the shell is mostly a thin tab bar plus plain content +- most screens read like raw tables rather than an application layout +- related information is split across separate full-screen tabs +- there is little top-level summary or visual orientation +- selected-state, metadata, and actions do not have a strong hierarchy +- settings consume a full screen even though they are low-frequency controls + +This makes the interface feel unfinished even when the underlying functionality is useful. + +## Design Options Considered + +### Option 1: Polish the Existing 4-Tab Layout + +Pros: + +- smallest change +- preserves current navigation model + +Cons: + +- keeps the same weak screen structure +- does not solve the “disconnected screens” feeling +- still makes the TUI feel table-first instead of task-first + +### Option 2: Two-Mode Shell with Shared Panels + +Pros: + +- supports direct A/B comparison of two layouts +- reuses the same state, actions, and rendering primitives +- lets the product discover whether the better default is operational or overview-first + +Cons: + +- more rendering work up front +- requires some root-level navigation reshaping + +### Option 3: Separate Commands for Separate Layouts + +Pros: + +- clean conceptual split + +Cons: + +- duplicates navigation and maintenance cost +- awkward for quick comparison +- too heavy for what is fundamentally one product decision + +## Recommendation + +Implement **Option 2: one TUI with two top-level layout modes**. + +This gives the user a real comparison without fragmenting the feature. The redesign should replace the current tab-first shell with a stronger application frame and treat memories, injection, history, and settings as composable panels. + +## Information Architecture + +### Top-Level Modes + +The TUI should have two primary layout modes: + +- `Inspector` +- `Dashboard` + +`Inspector` should be the default because the core memory-loop workflow is operational: scan records, inspect one, and apply lifecycle actions quickly. + +### Shared App Frame + +Both layouts should share a consistent frame: + +- header +- summary strip +- main content area +- footer help/status bar + +The header should show the current layout mode plus current memory-loop controls such as mode and activation policy. The summary strip should expose small high-value metrics at a glance. + +### Shared Data Panels + +The app should treat the following as reusable panels instead of separate full-screen destinations: + +- memory list +- selected memory detail +- injection tester +- recent injections +- refresh history +- settings controls + +Each layout can arrange these panels differently while reusing the same rendering logic and action messages. + +## Layout Designs + +### Inspector Layout + +`Inspector` is a working view for acting on memories. + +Recommended arrangement: + +- left pane: searchable/filterable memory list +- right pane: selected memory detail card +- right pane lower sections: lifecycle actions, provenance, usage, and outcome metadata +- optional compact secondary panel for injection testing or recent injections + +The list should dominate the layout. The right side should feel like an inspector card rather than a dump of fields. + +### Dashboard Layout + +`Dashboard` is a read-first overview for scanning system state. + +Recommended arrangement: + +- top row: metric cards +- upper body: status distribution and recent activity panels +- lower body: compact memory list with selection support +- side or lower panel: selected memory summary/detail + +The dashboard should still allow selection and action, but it should prioritize “what is happening?” over “work through this queue.” + +## Interaction Model + +### Navigation + +- top-level navigation switches between `Inspector` and `Dashboard` +- core list navigation remains keyboard-first +- selection stays stable when changing layouts whenever possible +- actions should dispatch the same lifecycle messages in either layout + +### Memory Workflow + +In `Inspector`: + +- arrows or `j`/`k` move through memories +- `/` enters search +- `f` cycles status filters +- `enter` expands or collapses long detail content +- single-keystroke lifecycle actions remain available + +In `Dashboard`: + +- arrow keys move between panels or list rows +- selecting a memory updates the detail panel in place +- the user should not need to jump to another screen to understand the selected record + +### Settings + +Settings should no longer be a dedicated full-screen view. They should become a lower-priority panel or drawer that keeps controls available without taking over the whole interface. + +### Injection Testing + +Injection testing should remain in the TUI, but integrated as a panel: + +- compact in `Inspector` +- richer and more visible in `Dashboard` + +This keeps it attached to the memory system rather than feeling like a disconnected tool. + +## Visual Language + +The TUI should move from “styled output” to “intentional dashboard.” + +### Visual Principles + +- restrained slate/gray base +- warm amber accent for focus and navigation +- green/yellow/red reserved for status semantics +- strong whitespace and panel separation +- more hierarchy, fewer cramped columns + +### Typography and Hierarchy + +Use: + +- bold section titles +- uppercase micro-labels for panel headings +- dim metadata for lower-priority text +- stronger selected state for rows and cards + +### Panels and Cards + +Prefer framed panels and compact cards over raw full-width tables. Tables can still be used where appropriate, but the main experience should rely on: + +- metric cards +- memory summary rows +- detail cards +- scoped footer hints + +## Implementation Boundaries + +This redesign should remain a presentation and navigation refactor. + +### Must Reuse + +- existing memory-loop state loading/saving +- existing lifecycle action messages +- existing injection test behavior +- existing settings mutation behavior + +### Should Change + +- root model layout mode and app frame +- rendering composition +- panel structure +- keyboard help and mode switching +- list/detail presentation + +### Should Not Change + +- memory-loop JSON schema +- generator and ranking behavior +- refresh command semantics +- lifecycle rules for candidate, active, suppressed, and archived records + +## Testing Strategy + +The risk is mostly in rendering and navigation regressions, so tests should focus on: + +- root view mode switching +- panel rendering with representative state +- stable selection and filter behavior across layouts +- settings and lifecycle action dispatch staying wired correctly + +Because the package currently has little direct TUI coverage, add focused tests around render helpers and layout behavior instead of trying to snapshot the entire app in one giant assertion. + +## Rollout + +Ship both layouts in the same command and let the user compare them in real use. After feedback: + +- choose the better default +- keep both if they serve distinct workflows +- or remove the weaker layout once the preferred direction is clear diff --git a/docs/plans/2026-03-26-memory-loop-tui-restyle-design.md b/docs/plans/2026-03-26-memory-loop-tui-restyle-design.md new file mode 100644 index 000000000..8bb4ce4f9 --- /dev/null +++ b/docs/plans/2026-03-26-memory-loop-tui-restyle-design.md @@ -0,0 +1,95 @@ +# Memory Loop TUI Restyle Design + +## Summary + +Restyle the existing four-tab memory-loop TUI without changing its information architecture. Keep `Memories`, `Injection`, `History`, and `Settings` as they are, but make the shell and panels feel more intentional and terminal-native, drawing visual cues from `gh-dash` and `posting`. + +## Goals + +- Keep the current tab layout and workflows intact. +- Make the TUI feel polished in both dark and light terminals. +- Use terminal-native styling instead of fixed backgrounds. +- Improve hierarchy, spacing, borders, and selection treatment. +- Reduce the spreadsheet feel of the data-heavy screens. + +## Non-Goals + +- No dashboard or inspector layout experiment. +- No state or navigation changes. +- No changes to memory-loop commands, actions, or storage. +- No theme that fights the user’s terminal background. + +## Visual Direction + +The UI should use the terminal as the base canvas and add structure on top of it. + +### Principles + +- default terminal background +- muted gray chrome +- one restrained warm accent for focus +- semantic colors only for statuses +- stronger spacing and quieter borders + +### Reference Traits + +From `gh-dash`: + +- compact, legible tab chrome +- strong contrast between active and inactive navigation +- crisp panel framing without heavy fill colors + +From `posting`: + +- calm typography hierarchy +- compact uppercase labels +- deliberate spacing that makes dense screens feel readable + +## Design Changes + +### Shell + +- restyle the top tab bar so it looks like app navigation rather than plain text tabs +- tighten the top status indicators for mode and policy +- improve the footer hint bar so it reads like secondary chrome + +### Panels + +- use a shared panel style for cards and bordered blocks +- use uppercase micro-labels for section headers +- use more padding inside cards so content is easier to scan + +### Tables and Lists + +- soften table headers +- make selected rows clearer with accent treatment and stronger foreground emphasis +- reduce visual noise in non-selected rows +- keep backgrounds transparent or minimal + +### Detail Cards + +- use badges more cleanly for kind/status/scope +- strengthen heading hierarchy +- reduce border clutter so content reads first + +## Implementation Boundaries + +This is a presentation-only change. + +Primary files: + +- `cmd/entire/cli/memorylooptui/styles.go` +- `cmd/entire/cli/memorylooptui/render.go` +- `cmd/entire/cli/memorylooptui/tab_memories.go` +- `cmd/entire/cli/memorylooptui/tab_injection.go` +- `cmd/entire/cli/memorylooptui/tab_history.go` +- `cmd/entire/cli/memorylooptui/tab_settings.go` + +## Testing Strategy + +Add focused render tests that verify: + +- improved tab bar formatting +- section labels and shell chrome +- restyled summary cards or panel labels +- existing tab content still renders under the same navigation model diff --git a/docs/plans/2026-03-26-memory-loop-tui-restyle.md b/docs/plans/2026-03-26-memory-loop-tui-restyle.md new file mode 100644 index 000000000..50a54d5e7 --- /dev/null +++ b/docs/plans/2026-03-26-memory-loop-tui-restyle.md @@ -0,0 +1,173 @@ +# Memory Loop TUI Restyle Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Restyle the existing memory-loop TUI so it feels polished and terminal-native without changing the four-tab information architecture. + +**Architecture:** Keep the current tab structure and state flow, and limit changes to the TUI presentation layer. Improve the shared style primitives and tab rendering so the shell, cards, and selected states feel closer to `gh-dash` and `posting` while still adapting to the user’s terminal. + +**Tech Stack:** Go, Bubble Tea, Bubbles, Lip Gloss, testify/require + +--- + +### Task 1: Add failing render tests for shell chrome + +**Files:** +- Create: `cmd/entire/cli/memorylooptui/render_test.go` +- Modify: `cmd/entire/cli/memorylooptui/render.go` + +**Step 1: Write the failing test** + +```go +func TestRenderTabBar_UsesStyledNavigationLabels(t *testing.T) { + t.Parallel() + + out := renderTabBar(newStyles(), tabMemories, 100, memoryloop.ModeAuto, memoryloop.ActivationPolicyReview) + + require.Contains(t, out, "1 Memories") + require.Contains(t, out, "auto") + require.Contains(t, out, "review") +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./cmd/entire/cli/memorylooptui -run TestRenderTabBar_UsesStyledNavigationLabels` + +Expected: FAIL once the test expects the new chrome markers or panel labels. + +**Step 3: Write minimal implementation** + +Restyle the shared shell renderers in `render.go` and style primitives in `styles.go`. + +**Step 4: Run test to verify it passes** + +Run: `go test ./cmd/entire/cli/memorylooptui -run TestRenderTabBar_UsesStyledNavigationLabels` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/memorylooptui/render_test.go cmd/entire/cli/memorylooptui/render.go cmd/entire/cli/memorylooptui/styles.go +git commit -m "test: add memory-loop TUI shell restyle coverage" +``` + +### Task 2: Restyle memories tab presentation + +**Files:** +- Modify: `cmd/entire/cli/memorylooptui/tab_memories.go` +- Modify: `cmd/entire/cli/memorylooptui/styles.go` +- Test: `cmd/entire/cli/memorylooptui/render_test.go` + +**Step 1: Write the failing test** + +```go +func TestMemoriesView_UsesPanelLabelsAndStyledDetailCard(t *testing.T) { + t.Parallel() + + out := newRootModelForTest().View() + + require.Contains(t, out, "MEMORY LIST") + require.Contains(t, out, "WHY") +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./cmd/entire/cli/memorylooptui -run TestMemoriesView_UsesPanelLabelsAndStyledDetailCard` + +Expected: FAIL before the memory tab gets the new styling treatment. + +**Step 3: Write minimal implementation** + +Tighten memory table styling, selected row treatment, filter chips, and detail card presentation. + +**Step 4: Run test to verify it passes** + +Run: `go test ./cmd/entire/cli/memorylooptui -run TestMemoriesView_UsesPanelLabelsAndStyledDetailCard` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/memorylooptui/tab_memories.go cmd/entire/cli/memorylooptui/styles.go cmd/entire/cli/memorylooptui/render_test.go +git commit -m "feat: restyle memory-loop memories tab" +``` + +### Task 3: Restyle injection, history, and settings panels + +**Files:** +- Modify: `cmd/entire/cli/memorylooptui/tab_injection.go` +- Modify: `cmd/entire/cli/memorylooptui/tab_history.go` +- Modify: `cmd/entire/cli/memorylooptui/tab_settings.go` +- Modify: `cmd/entire/cli/memorylooptui/styles.go` +- Test: `cmd/entire/cli/memorylooptui/render_test.go` + +**Step 1: Write the failing test** + +```go +func TestSecondaryTabs_UseSharedPanelChrome(t *testing.T) { + t.Parallel() + + root := newRootModelForTest() + root.activeTab = tabInjection + + require.Contains(t, root.View(), "PROMPT TESTER") +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./cmd/entire/cli/memorylooptui -run TestSecondaryTabs_UseSharedPanelChrome` + +Expected: FAIL until secondary tabs use the improved panel styling. + +**Step 3: Write minimal implementation** + +Apply the shared visual language to injection/history/settings without changing their behavior. + +**Step 4: Run test to verify it passes** + +Run: `go test ./cmd/entire/cli/memorylooptui -run TestSecondaryTabs_UseSharedPanelChrome` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/entire/cli/memorylooptui/tab_injection.go cmd/entire/cli/memorylooptui/tab_history.go cmd/entire/cli/memorylooptui/tab_settings.go cmd/entire/cli/memorylooptui/styles.go cmd/entire/cli/memorylooptui/render_test.go +git commit -m "feat: restyle memory-loop secondary tabs" +``` + +### Task 4: Final verification + +**Files:** +- Modify: `cmd/entire/cli/memorylooptui/*.go` +- Test: `cmd/entire/cli/memorylooptui/render_test.go` + +**Step 1: Format** + +Run: `gofmt -w cmd/entire/cli/memorylooptui/*.go` + +Expected: formatted files only + +**Step 2: Run package tests** + +Run: `go test ./cmd/entire/cli/memorylooptui` + +Expected: PASS + +**Step 3: Run related CLI tests** + +Run: `go test ./cmd/entire/cli -run 'Test(RunMemoryLoop|SetMemoryLoop|RunMemoryLoopShow)'` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add cmd/entire/cli/memorylooptui/*.go +git commit -m "fix: polish memory-loop TUI restyle" +``` diff --git a/go.mod b/go.mod index 98ba84476..fa709cf0a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/entireio/cli go 1.26.1 require ( + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 @@ -18,6 +20,7 @@ require ( github.com/zricethezav/gitleaks/v8 v8.30.1 golang.org/x/mod v0.34.0 golang.org/x/term v0.41.0 + modernc.org/sqlite v1.47.0 ) require ( @@ -36,8 +39,6 @@ require ( github.com/bodgit/sevenzip v1.6.0 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect @@ -86,11 +87,13 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nwaples/rardecode/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rs/zerolog v1.33.0 // indirect @@ -112,7 +115,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect @@ -120,4 +123,7 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 2faf9cf23..9a95c77fc 100644 --- a/go.sum +++ b/go.sum @@ -167,6 +167,8 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -246,6 +248,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= @@ -261,6 +265,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/posthog/posthog-go v1.11.1 h1:P0MHlerMW9rNpjW+1szNsJ5HbdYJUv/9lF2DWZCHztE= github.com/posthog/posthog-go v1.11.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -352,8 +358,8 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= -golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -470,6 +476,8 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -524,6 +532,34 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=