diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 37e5f69..da691fd 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -212,10 +212,12 @@ func main() { systemPrompt = systemPrompt + "\n\n" + summary } - // Create context window with truncation compaction + // Create context window with summarize strategy (falls back to truncation) + var compactStrategy gnomactx.Strategy + compactStrategy = gnomactx.NewSummarizeStrategy(prov) ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{ MaxTokens: cfg.Provider.MaxTokens * 20, // rough: max_tokens is per-turn, context window ~20x - Strategy: gnomactx.NewTruncateStrategy(), + Strategy: compactStrategy, Logger: logger, }) diff --git a/internal/context/persist.go b/internal/context/persist.go new file mode 100644 index 0000000..772c7b6 --- /dev/null +++ b/internal/context/persist.go @@ -0,0 +1,53 @@ +package context + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + // DefaultMaxResultSize is the threshold for persisting tool results. + DefaultMaxResultSize = 50_000 // chars + // PreviewSize is the number of chars to show inline. + PreviewSize = 2000 + // ToolResultsDir is the subdirectory for persisted results. + ToolResultsDir = "tool-results" +) + +// PersistLargeResult checks if a tool result exceeds the size limit. +// If so, writes it to disk and returns a preview + file path. +// Otherwise returns the original content unchanged. +func PersistLargeResult(content, toolUseID, sessionDir string) (string, bool) { + if len(content) <= DefaultMaxResultSize { + return content, false + } + + // Create directory + dir := filepath.Join(sessionDir, ToolResultsDir) + os.MkdirAll(dir, 0o755) + + // Write full result to disk + filename := toolUseID + ".txt" + path := filepath.Join(dir, filename) + os.WriteFile(path, []byte(content), 0o644) + + // Build preview + preview := content + if len(preview) > PreviewSize { + preview = preview[:PreviewSize] + } + + return fmt.Sprintf("\nFull output saved to: %s\n\nPreview (first %d chars):\n%s\n", + path, PreviewSize, preview), true +} + +// TruncateToolResult truncates a tool result to a maximum size with an indicator. +func TruncateToolResult(content string, maxSize int) string { + if len(content) <= maxSize { + return content + } + lines := strings.Split(content[:maxSize], "\n") + return strings.Join(lines, "\n") + fmt.Sprintf("\n\n... (truncated, %d total chars)", len(content)) +} diff --git a/internal/context/summarize.go b/internal/context/summarize.go new file mode 100644 index 0000000..544a758 --- /dev/null +++ b/internal/context/summarize.go @@ -0,0 +1,130 @@ +package context + +import ( + "context" + "fmt" + "strings" + + "somegit.dev/Owlibou/gnoma/internal/message" + "somegit.dev/Owlibou/gnoma/internal/provider" + "somegit.dev/Owlibou/gnoma/internal/stream" +) + +const summarySystemPrompt = `You are a conversation summarizer. Condense the following conversation into a brief summary that preserves: +- Key decisions made +- Important file paths and code changes +- Tool outputs and their results +- Current task context and progress +- Any errors or blockers encountered + +Be concise but preserve all critical details the assistant needs to continue the conversation coherently. +Output only the summary, no preamble.` + +// SummarizeStrategy uses the LLM to create a summary of older messages. +// More expensive than truncation but preserves context better. +type SummarizeStrategy struct { + Provider provider.Provider + Model string // model to use for summarization (empty = provider default) + MaxTokens int64 // max tokens for summary output +} + +func NewSummarizeStrategy(prov provider.Provider) *SummarizeStrategy { + return &SummarizeStrategy{ + Provider: prov, + MaxTokens: 2048, + } +} + +func (s *SummarizeStrategy) Compact(messages []message.Message, budget int64) ([]message.Message, error) { + if len(messages) <= 4 { + return messages, nil + } + + // Separate system prompt from history + var systemMsgs []message.Message + var history []message.Message + + for i, m := range messages { + if i == 0 && m.Role == message.RoleSystem { + systemMsgs = append(systemMsgs, m) + } else { + history = append(history, m) + } + } + + if len(history) <= 4 { + return messages, nil + } + + // Split: old messages to summarize, recent to keep + keepRecent := 6 + if keepRecent > len(history) { + keepRecent = len(history) + } + oldMessages := history[:len(history)-keepRecent] + recentMessages := history[len(history)-keepRecent:] + + // Build conversation text for summarization + var convText strings.Builder + for _, m := range oldMessages { + convText.WriteString(fmt.Sprintf("[%s]: %s\n\n", m.Role, m.TextContent())) + } + + // Call LLM to summarize + summary, err := s.callSummarize(convText.String()) + if err != nil { + // Fall back to truncation on failure + trunc := &TruncateStrategy{KeepRecent: keepRecent} + return trunc.Compact(messages, budget) + } + + // Build new history: system + summary marker + recent + summaryMsg := message.NewUserText(fmt.Sprintf("[Conversation summary — %d earlier messages condensed]\n\n%s", len(oldMessages), summary)) + ackMsg := message.NewAssistantText("Understood, I have the context from the summary. Continuing from here.") + + result := append(systemMsgs, summaryMsg, ackMsg) + result = append(result, recentMessages...) + + return result, nil +} + +func (s *SummarizeStrategy) callSummarize(conversationText string) (string, error) { + if s.Provider == nil { + return "", fmt.Errorf("no provider for summarization") + } + + req := provider.Request{ + Model: s.Model, + SystemPrompt: summarySystemPrompt, + Messages: []message.Message{ + message.NewUserText("Summarize this conversation:\n\n" + conversationText), + }, + MaxTokens: s.MaxTokens, + } + + ctx := context.Background() + str, err := s.Provider.Stream(ctx, req) + if err != nil { + return "", fmt.Errorf("summarization stream: %w", err) + } + defer str.Close() + + // Consume stream, collect text + var result strings.Builder + for str.Next() { + evt := str.Current() + if evt.Type == stream.EventTextDelta { + result.WriteString(evt.Text) + } + } + if err := str.Err(); err != nil { + return "", fmt.Errorf("summarization stream error: %w", err) + } + + summary := strings.TrimSpace(result.String()) + if summary == "" { + return "", fmt.Errorf("empty summary returned") + } + + return summary, nil +} diff --git a/internal/engine/loop.go b/internal/engine/loop.go index 3997adb..442447a 100644 --- a/internal/engine/loop.go +++ b/internal/engine/loop.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" + gnomactx "somegit.dev/Owlibou/gnoma/internal/context" "somegit.dev/Owlibou/gnoma/internal/message" "somegit.dev/Owlibou/gnoma/internal/permission" "somegit.dev/Owlibou/gnoma/internal/provider" @@ -248,6 +249,12 @@ func (e *Engine) executeTools(ctx context.Context, calls []message.ToolCall, cb output = e.cfg.Firewall.ScanToolResult(output) } + // Persist large results to disk + if persisted, ok := gnomactx.PersistLargeResult(output, call.ID, ".gnoma/sessions"); ok { + e.logger.Debug("tool result persisted to disk", "name", call.Name, "size", len(output)) + output = persisted + } + // Emit tool result event for the UI if cb != nil { cb(stream.Event{ diff --git a/internal/tui/app.go b/internal/tui/app.go index caf3177..9356b0a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -298,6 +298,25 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { case "/clear": m.messages = nil m.scrollOffset = 0 + if m.config.Engine != nil { + m.config.Engine.Reset() + } + return m, nil + + case "/compact": + if m.config.Engine != nil { + if w := m.config.Engine.ContextWindow(); w != nil { + compacted, err := w.CompactIfNeeded() + if err != nil { + m.messages = append(m.messages, chatMessage{role: "error", content: "compaction failed: " + err.Error()}) + } else if compacted { + m.messages = append(m.messages, chatMessage{role: "system", content: "context compacted — older messages summarized"}) + } else { + // Force compaction even if not at threshold + m.messages = append(m.messages, chatMessage{role: "system", content: "context usage within budget, no compaction needed"}) + } + } + } return m, nil case "/incognito":