/ internal / context / summarize.go
summarize.go
  1  package context
  2  
  3  import (
  4  	"bytes"
  5  	"context"
  6  	"encoding/json"
  7  	"fmt"
  8  	"strings"
  9  
 10  	"github.com/Kocoro-lab/ShanClaw/internal/client"
 11  )
 12  
 13  const summarizePrompt = `Compress the following conversation into a concise summary using a two-phase approach.
 14  
 15  Phase 1 — Write a chronological analysis inside <analysis> tags:
 16  - Walk through the conversation in order
 17  - Note every user correction, decision, or preference change
 18  - Track files read, modified, or created
 19  - Record errors, blockers, and their resolutions
 20  - Note which skills were activated via use_skill and any tool_search schema loads
 21  
 22  Phase 2 — Write the final summary inside <summary> tags. The summary MUST contain these labeled sections in this order:
 23  
 24  ## Current task & next steps
 25  What the user is working on and what the model was about to do when compacted.
 26  
 27  ## User corrections & decisions
 28  Every correction, preference, or explicit decision the user made. Highest-priority content — never omit.
 29  
 30  ## Open files / important reads
 31  Files the model has read this session and still needs awareness of. List one per line as "path — one-line purpose" (e.g. "internal/agent/loop.go — core agentic loop being modified"). Do NOT include file contents; only paths + purpose. Omit files that were only glanced at and are no longer relevant.
 32  
 33  ## Active skill policies
 34  Skills activated via use_skill whose guidance still applies. One bullet per skill: "skill-name — one-line what-it-enforces" (e.g. "test-driven-development — write failing test before implementation"). Do NOT reproduce SKILL.md bodies.
 35  
 36  ## Loaded tool capabilities
 37  Tools whose schemas were pulled in via tool_search this session. One comma-separated line (e.g. "Loaded: linear_search_issues, linear_create_issue, github_list_prs"). Omit this section entirely if tool_search was never called.
 38  
 39  Rules:
 40  - Be factual and brief. The goal is continuation, not exposition.
 41  - If a section has no content, omit its header rather than writing "none" or "N/A".
 42  - Do not add sections beyond the five above.
 43  - If the conversation does not fit the five-section structure (e.g. very short,
 44    error-dominated, or tool_search-heavy), put a single plain-prose paragraph
 45    summarising the work so far inside <summary>…</summary> instead of the five
 46    labeled sections. Never return an empty response — an empty summary silently
 47    skips context compaction and wastes the analysis pass.
 48  
 49  Format your response as:
 50  <analysis>
 51  [chronological walkthrough]
 52  </analysis>
 53  <summary>
 54  [structured summary with the sections above]
 55  </summary>`
 56  
 57  // Completer is the interface for making LLM completion calls.
 58  // Satisfied by *client.GatewayClient.
 59  type Completer interface {
 60  	Complete(ctx context.Context, req client.CompletionRequest) (*client.CompletionResponse, error)
 61  }
 62  
 63  // buildTranscript 将消息序列化为文本 transcript,跳过 system 消息。
 64  func buildTranscript(messages []client.Message) string {
 65  	var sb strings.Builder
 66  	for _, m := range messages {
 67  		if m.Role == "system" {
 68  			continue
 69  		}
 70  		if t := messageText(m); t != "" {
 71  			fmt.Fprintf(&sb, "[%s]: %s\n\n", m.Role, t)
 72  		}
 73  	}
 74  	return sb.String()
 75  }
 76  
 77  // GenerateSummary calls the LLM (small tier) to summarize a conversation.
 78  // It strips the system message from the input to avoid wasting tokens.
 79  // Serializes both plain text and block content (tool_use, tool_result).
 80  func GenerateSummary(ctx context.Context, c Completer, messages []client.Message) (string, client.Usage, error) {
 81  	req := client.CompletionRequest{
 82  		Messages: []client.Message{
 83  			{Role: "system", Content: client.NewTextContent(summarizePrompt)},
 84  			{Role: "user", Content: client.NewTextContent(buildTranscript(messages))},
 85  		},
 86  		ModelTier:   "small",
 87  		Temperature: 0.2,
 88  		MaxTokens:   2000,
 89  	}
 90  
 91  	resp, err := c.Complete(ctx, req)
 92  	if err != nil {
 93  		return "", client.Usage{}, fmt.Errorf("summarization failed: %w", err)
 94  	}
 95  
 96  	return extractSummary(resp.OutputText), resp.Usage, nil
 97  }
 98  
 99  const userSummarizePrompt = `You are a conversation summarizer. Read the following conversation and produce a clear, well-structured Markdown summary for a human reader.
100  
101  Requirements:
102  - Write in the SAME LANGUAGE as the conversation (if the conversation is in Chinese, write in Chinese; if in English, write in English, etc.)
103  - Use Markdown formatting with headers and bullet points
104  - Focus on: what was discussed, key decisions made, work completed, and remaining action items
105  - Be concise but comprehensive — a reader should understand the conversation's outcome without reading the full transcript
106  - Do NOT include internal LLM terminology (tool_call, context window, tokens, etc.)
107  - Do NOT wrap the output in code fences — output raw Markdown directly`
108  
109  // SummarizeForUser 调用 LLM 生成面向人类阅读的会话摘要。
110  func SummarizeForUser(ctx context.Context, c Completer, messages []client.Message) (string, error) {
111  	req := client.CompletionRequest{
112  		Messages: []client.Message{
113  			{Role: "system", Content: client.NewTextContent(userSummarizePrompt)},
114  			{Role: "user", Content: client.NewTextContent(buildTranscript(messages))},
115  		},
116  		ModelTier:   "small",
117  		Temperature: 0.2,
118  		MaxTokens:   2000,
119  	}
120  
121  	resp, err := c.Complete(ctx, req)
122  	if err != nil {
123  		return "", fmt.Errorf("user summarization failed: %w", err)
124  	}
125  
126  	return strings.TrimSpace(resp.OutputText), nil
127  }
128  
129  // extractSummary extracts the <summary> content from a two-phase response.
130  // If <summary> tags are present, returns their content.
131  // If missing, strips any <analysis> block and returns the remainder.
132  // Never returns raw <analysis> content — ShapeHistory injects the summary verbatim.
133  func extractSummary(raw string) string {
134  	raw = strings.TrimSpace(raw)
135  
136  	// Try to extract <summary>...</summary>
137  	if _, after, found := strings.Cut(raw, "<summary>"); found {
138  		if content, _, ok := strings.Cut(after, "</summary>"); ok {
139  			return strings.TrimSpace(content)
140  		}
141  		// Opening tag but no closing — take everything after the tag
142  		return strings.TrimSpace(after)
143  	}
144  
145  	// No <summary> tags — strip <analysis>...</analysis> and return remainder
146  	result := raw
147  	for {
148  		before, rest, found := strings.Cut(result, "<analysis>")
149  		if !found {
150  			break
151  		}
152  		_, afterClose, closed := strings.Cut(rest, "</analysis>")
153  		if !closed {
154  			// Opening tag but no closing — strip from <analysis> onward
155  			result = before
156  			break
157  		}
158  		result = before + afterClose
159  	}
160  
161  	result = strings.TrimSpace(result)
162  	if result == "" {
163  		// Everything was analysis with no summary — return empty.
164  		// ShapeHistory handles empty summaries gracefully (sliding window only).
165  		// Returning raw here would leak <analysis> scratch work into context.
166  		return ""
167  	}
168  	return result
169  }
170  
171  // messageText extracts readable text from a message, handling both plain text
172  // and block content (tool_use, tool_result, text blocks).
173  func messageText(m client.Message) string {
174  	// Plain text message
175  	if !m.Content.HasBlocks() {
176  		return m.Content.Text()
177  	}
178  
179  	// Block content — serialize each block type
180  	var sb strings.Builder
181  	for _, b := range m.Content.Blocks() {
182  		if text := summarizeContentBlock(b); text != "" {
183  			sb.WriteString(text)
184  			sb.WriteString(" ")
185  		}
186  	}
187  	return strings.TrimSpace(sb.String())
188  }
189  
190  func summarizeContentBlock(b client.ContentBlock) string {
191  	switch b.Type {
192  	case "text":
193  		return b.Text
194  	case "tool_use":
195  		return summarizeToolUse(b)
196  	case "tool_result":
197  		return summarizeToolResult(b)
198  	case "tool_reference":
199  		if b.ToolName != "" {
200  			return fmt.Sprintf("[tool_reference: %s]", b.ToolName)
201  		}
202  	}
203  	return ""
204  }
205  
206  func summarizeToolUse(b client.ContentBlock) string {
207  	if b.Name == "" {
208  		return ""
209  	}
210  	args := compactToolInput(b.Input)
211  	if args == "" {
212  		return fmt.Sprintf("[tool_call: %s]", b.Name)
213  	}
214  	return fmt.Sprintf("[tool_call: %s %s]", b.Name, args)
215  }
216  
217  func summarizeToolResult(b client.ContentBlock) string {
218  	// Truncate base text BEFORE appending refs so "Loaded tools: ..." survives
219  	// near-limit tool_result bodies. Refs carry the tool_search loaded-schema
220  	// names — surfacing them in the summary is the whole point of this helper,
221  	// so we keep them in full rather than a second-pass truncate that could
222  	// clip them.
223  	text := truncateSummaryText(strings.TrimSpace(client.ToolResultText(b)), 450)
224  	if refs := toolReferenceNames(b); len(refs) > 0 {
225  		refText := "Loaded tools: " + strings.Join(refs, ", ")
226  		if text == "" {
227  			text = refText
228  		} else {
229  			text += "\n" + refText
230  		}
231  	}
232  	if text == "" {
233  		return ""
234  	}
235  	return fmt.Sprintf("[tool_result: %s]", text)
236  }
237  
238  func toolReferenceNames(b client.ContentBlock) []string {
239  	// Comma-ok assertion is safe when ToolContent is a nil interface or carries
240  	// any non-[]ContentBlock value (e.g. the string shape — see ToolResultText).
241  	// Returns (nil, false) without panicking in both cases.
242  	nested, ok := b.ToolContent.([]client.ContentBlock)
243  	if !ok {
244  		return nil
245  	}
246  	names := make([]string, 0, len(nested))
247  	for _, child := range nested {
248  		if child.Type == "tool_reference" && child.ToolName != "" {
249  			names = append(names, child.ToolName)
250  		}
251  	}
252  	return names
253  }
254  
255  func compactToolInput(raw json.RawMessage) string {
256  	trimmed := strings.TrimSpace(string(raw))
257  	if trimmed == "" || trimmed == "null" || trimmed == "{}" || trimmed == "[]" {
258  		return ""
259  	}
260  	var buf bytes.Buffer
261  	if err := json.Compact(&buf, raw); err == nil {
262  		return truncateSummaryText(buf.String(), 240)
263  	}
264  	return truncateSummaryText(trimmed, 240)
265  }
266  
267  func truncateSummaryText(text string, maxRunes int) string {
268  	if maxRunes <= 0 {
269  		return ""
270  	}
271  	r := []rune(text)
272  	if len(r) <= maxRunes {
273  		return text
274  	}
275  	return string(r[:maxRunes]) + "..."
276  }