Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/logging"
"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/summarize"
"github.com/entireio/cli/cmd/entire/cli/trailers"
Expand Down Expand Up @@ -320,7 +321,12 @@ func generateCheckpointSummary(w, _ io.Writer, store *checkpoint.GitStore, check
ctx := context.Background()
logging.Info(ctx, "generating checkpoint summary")

summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, nil)
s, sErr := settings.Load()
if sErr != nil {
s = nil
}
gen := summarize.ResolveGenerator(s)
summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, gen)
if err != nil {
return fmt.Errorf("failed to generate summary: %w", err)
}
Expand Down
54 changes: 54 additions & 0 deletions cmd/entire/cli/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,60 @@ func applyDefaults(settings *EntireSettings) {
}
}

// summarizeOpts extracts the summarize sub-map from strategy_options, or returns nil.
func (s *EntireSettings) summarizeOpts() map[string]any {
if s.StrategyOptions == nil {
return nil
}
opts, ok := s.StrategyOptions["summarize"].(map[string]any)
if !ok {
return nil
}
return opts
}

// SummarizeProvider returns the configured summarization provider name.
// Returns empty string if not set (caller should default to Claude).
func (s *EntireSettings) SummarizeProvider() string {
opts := s.summarizeOpts()
if opts == nil {
return ""
}
provider, ok := opts["provider"].(string)
if !ok {
return ""
}
return provider
}

// SummarizeModel returns the configured model for the summarization provider.
// Returns empty string if not set (caller should use the provider's default).
func (s *EntireSettings) SummarizeModel() string {
opts := s.summarizeOpts()
if opts == nil {
return ""
}
model, ok := opts["model"].(string)
if !ok {
return ""
}
return model
}

// SummarizeAPIKey returns the configured API key for the summarization provider.
// Returns empty string if not set.
func (s *EntireSettings) SummarizeAPIKey() string {
opts := s.summarizeOpts()
if opts == nil {
return ""
}
apiKey, ok := opts["api_key"].(string)
if !ok {
return ""
}
return apiKey
}

// IsSummarizeEnabled checks if auto-summarize is enabled in settings.
// Returns false by default if settings cannot be loaded or the key is missing.
func IsSummarizeEnabled() bool {
Expand Down
60 changes: 60 additions & 0 deletions cmd/entire/cli/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,66 @@ func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) {
}
}

func TestSummarizeProvider_Empty(t *testing.T) {
t.Parallel()
s := &EntireSettings{}
if got := s.SummarizeProvider(); got != "" {
t.Errorf("expected empty provider, got %q", got)
}
}

func TestSummarizeProvider_Set(t *testing.T) {
t.Parallel()
s := &EntireSettings{
StrategyOptions: map[string]any{
"summarize": map[string]any{"provider": "openai"},
},
}
if got := s.SummarizeProvider(); got != "openai" {
t.Errorf("expected provider openai, got %q", got)
}
}

func TestSummarizeModel_Empty(t *testing.T) {
t.Parallel()
s := &EntireSettings{}
if got := s.SummarizeModel(); got != "" {
t.Errorf("expected empty model, got %q", got)
}
}

func TestSummarizeModel_Set(t *testing.T) {
t.Parallel()
s := &EntireSettings{
StrategyOptions: map[string]any{
"summarize": map[string]any{"model": "gpt-5-mini"},
},
}
if got := s.SummarizeModel(); got != "gpt-5-mini" {
t.Errorf("expected model gpt-5-mini, got %q", got)
}
}

func TestSummarizeAPIKey_Empty(t *testing.T) {
t.Parallel()
s := &EntireSettings{}
if got := s.SummarizeAPIKey(); got != "" {
t.Errorf("expected empty api_key, got %q", got)
}
}

func TestSummarizeAPIKey_Set(t *testing.T) {
t.Parallel()
s := &EntireSettings{
StrategyOptions: map[string]any{
"summarize": map[string]any{"api_key": "sk-test"},
},
}
if got := s.SummarizeAPIKey(); got != "sk-test" {
t.Errorf("expected api_key sk-test, got %q", got)
}
}

// containsUnknownField checks if the error message indicates an unknown field
func containsUnknownField(msg string) bool {
// Go's json package reports unknown fields with this message format
Expand Down
26 changes: 26 additions & 0 deletions cmd/entire/cli/strategy/auto_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/logging"
"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/trailers"

"github.com/go-git/go-git/v5"
Expand Down Expand Up @@ -258,6 +260,29 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository
// Write committed checkpoint using the checkpoint store
// Pass TranscriptPath so writeTranscript generates content_hash.txt
transcriptPath := filepath.Join(ctx.MetadataDirAbs, paths.TranscriptFileName)

// Generate summary if summarization is enabled (non-blocking)
var cpSummary *checkpoint.Summary
if settings.IsSummarizeEnabled() {
if transcriptBytes, readErr := os.ReadFile(transcriptPath); readErr == nil && len(transcriptBytes) > 0 { //nolint:gosec // path computed from session metadata
scoped := summarize.ScopeTranscript(transcriptBytes, ctx.StepTranscriptStart, ctx.AgentType)
if len(scoped) > 0 {
s, sErr := settings.Load()
if sErr != nil {
s = nil
}
gen := summarize.ResolveGenerator(s)
sumCtx := logging.WithComponent(context.Background(), "summarize")
var genErr error
cpSummary, genErr = summarize.GenerateFromTranscript(sumCtx, scoped, filesTouched, ctx.AgentType, gen)
if genErr != nil {
logging.Warn(sumCtx, "auto-commit summary generation failed",
slog.String("session_id", sessionID),
slog.String("error", genErr.Error()))
}
}
}
}
err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: sessionID,
Expand All @@ -274,6 +299,7 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository
TokenUsage: ctx.TokenUsage,
CheckpointsCount: 1, // Each auto-commit checkpoint = 1
FilesTouched: filesTouched, // Track modified files (same as manual-commit)
Summary: cpSummary,
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to write committed checkpoint: %w", err)
Expand Down
7 changes: 6 additions & 1 deletion cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,13 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
scopedTranscript = transcript.SliceFromLine(sessionData.Transcript, state.CheckpointTranscriptStart)
}
if len(scopedTranscript) > 0 {
s, sErr := settings.Load()
if sErr != nil {
s = nil
}
gen := summarize.ResolveGenerator(s)
var err error
summary, err = summarize.GenerateFromTranscript(summarizeCtx, scopedTranscript, sessionData.FilesTouched, state.AgentType, nil)
summary, err = summarize.GenerateFromTranscript(summarizeCtx, scopedTranscript, sessionData.FilesTouched, state.AgentType, gen)
if err != nil {
logging.Warn(summarizeCtx, "summary generation failed",
slog.String("session_id", state.SessionID),
Expand Down
132 changes: 132 additions & 0 deletions cmd/entire/cli/summarize/openai.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package summarize

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
)

const openAIDefaultModel = "gpt-5-mini-mini"
const openAIAPIEndpoint = "https://api.openai.com/v1/chat/completions"

// OpenAIGenerator generates summaries using the OpenAI API.
type OpenAIGenerator struct {
// APIKey is the OpenAI API key.
// Falls back to OPENAI_API_KEY environment variable if empty.
APIKey string

// Model is the OpenAI model to use.
// Defaults to openAIDefaultModel if empty.
Model string

// HTTPClient is used for API requests. Defaults to http.DefaultClient if nil.
HTTPClient *http.Client

// endpoint overrides the API URL (used in tests).
endpoint string
}

type openAIRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
}

type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}

type openAIResponse struct {
Choices []openAIChoice `json:"choices"`
Error *openAIError `json:"error,omitempty"`
}

type openAIChoice struct {
Message openAIMessage `json:"message"`
}

type openAIError struct {
Message string `json:"message"`
Type string `json:"type"`
}

// Generate creates a summary by calling the OpenAI chat completions API.
func (g *OpenAIGenerator) Generate(ctx context.Context, input Input) (*checkpoint.Summary, error) {
apiKey := g.APIKey
if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY")
}
if apiKey == "" {
return nil, errors.New("OpenAI API key not configured: set summarize.api_key in settings or OPENAI_API_KEY environment variable")
}

model := g.Model
if model == "" {
model = openAIDefaultModel
}

transcriptText := FormatCondensedTranscript(input)
prompt := buildSummarizationPrompt(transcriptText)

reqBody := openAIRequest{
Model: model,
Messages: []openAIMessage{{Role: "user", Content: prompt}},
}
reqBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal OpenAI request: %w", err)
}

endpoint := g.endpoint
if endpoint == "" {
endpoint = openAIAPIEndpoint
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBytes))
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)

client := g.HTTPClient
if client == nil {
client = http.DefaultClient
}

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call OpenAI API: %w", err)
}
defer resp.Body.Close()

var apiResp openAIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to parse OpenAI response: %w", err)
}

if resp.StatusCode != http.StatusOK {
errMsg := "unknown error"
if apiResp.Error != nil {
errMsg = apiResp.Error.Message
}
return nil, fmt.Errorf("OpenAI API error (status %d): %s", resp.StatusCode, errMsg)
}

if len(apiResp.Choices) == 0 {
return nil, errors.New("OpenAI API returned no choices")
}

resultJSON := extractJSONFromMarkdown(apiResp.Choices[0].Message.Content)
var summary checkpoint.Summary
if err := json.Unmarshal([]byte(resultJSON), &summary); err != nil {
return nil, fmt.Errorf("failed to parse summary JSON from OpenAI: %w (response: %s)", err, resultJSON)
}

return &summary, nil
}
Loading