Files
gnoma/internal/context/summarize.go
vikingowl cb2d63d06f feat: Ollama/gemma4 compat — /init flow, stream filter, safety fixes
provider/openai:
- Fix doubled tool call args (argsComplete flag): Ollama sends complete
  args in the first streaming chunk then repeats them as delta, causing
  doubled JSON and 400 errors in elfs
- Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep)
- Add Reasoning field support for Ollama thinking output

cmd/gnoma:
- Early TTY detection so logger is created with correct destination
  before any component gets a reference to it (fixes slog WARN bleed
  into TUI textarea)

permission:
- Exempt spawn_elfs and agent tools from safety scanner: elf prompt
  text may legitimately mention .env/.ssh/credentials patterns and
  should not be blocked

tui/app:
- /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge
  (ask for plain text output) → TUI fallback write from streamBuf
- looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback
  content before writing (reject refusals, strip narrative preambles)
- Collapse thinking output to 3 lines; ctrl+o to expand (live stream
  and committed messages)
- Stream-level filter for model pseudo-tool-call blocks: suppresses
  <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|>
  from entering streamBuf across chunk boundaries
- sanitizeAssistantText regex covers both block formats
- Reset streamFilterClose at every turn start
2026-04-05 19:24:51 +02:00

134 lines
3.8 KiB
Go

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.
// Adjust split to never orphan tool results — the assistant message with
// matching tool calls must stay in the recent window with its results.
keepRecent := 6
if keepRecent > len(history) {
keepRecent = len(history)
}
splitAt := safeSplitPoint(history, len(history)-keepRecent)
oldMessages := history[:splitAt]
recentMessages := history[splitAt:]
// 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
}