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 }