Skip to content

Commit 89a6f7e

Browse files
committed
Support compact and bgcompact options in chat.save
1 parent 705514b commit 89a6f7e

8 files changed

Lines changed: 174 additions & 86 deletions

File tree

share/mai/prompts/compact.md

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,10 @@
1-
I'm going to provide you with a conversation history between a user and an AI assistant. Your task is to analyze the entire conversation and provide a concise, focused response that addresses the core of the user's questions and needs.
1+
Analyze the conversation history and produce a compact saved-context summary.
22

3-
This response should:
4-
1. Synthesize all the important information from the conversation
5-
2. Remove repetitive or redundant elements
6-
3. Maintain all key insights and valuable content
7-
4. Be presented as a single, coherent response
8-
5. Focus on providing the most helpful answer to what the user is ultimately trying to accomplish
3+
Focus on:
94

10-
This helps create a cleaner, more efficient conversation that delivers the same value in a more concise format.
5+
* Core goals, decisions, and outcomes.
6+
* Important technical facts, filenames, commands, settings, and constraints.
7+
* Open questions, unresolved problems, and next steps.
8+
* Relevant annotations that help resume the conversation without rereading the full log.
119

12-
Okay, here are a few prompt options, varying in detail and tone, suitable for a language mode query focused on compacting a conversation log. I've categorized them by increasing complexity:
13-
14-
**1. Basic Prompt (Good starting point):**
15-
16-
"Summarize the following conversation log. Focus on the key topics, decisions, and outcomes. Keep the summary concise – no more than 5-7 sentences."
17-
18-
**2. Slightly More Detailed Prompt:**
19-
20-
"You are a skilled summarizer of conversation logs. Please read the following conversation log and generate a short summary (approximately 80-100 words) that captures the essence of the conversation. Highlight the most important points – decisions made, issues discussed, and ultimately, the result of the interaction. Do not include unnecessary details or personal opinions."
21-
22-
**3. Prompt with Emphasis on Relevance:**
23-
24-
"Analyze the following conversation log. Identify the core topics discussed and the *most relevant* information. Craft a summary (around 100-150 words) that answers the question: 'What happened in this conversation, and what's the key takeaway?' Prioritize the information that directly impacts [mention a specific goal, e.g., the next step, a decision, understanding the issue]."
25-
26-
**4. Advanced Prompt (Best for complex logs):**
27-
28-
"You are an expert assistant tasked with distilling key information from a conversation log. Read the following log (provide the log here – consider using a JSON format for better structure if possible). Your goal is to produce a short, impactful summary (approximately 120-150 words) that focuses on:
29-
* **Identifying the central themes/topics.**
30-
* **Highlighting the crucial decisions and their implications.**
31-
* **Pinpointing the ultimate outcome or resolution.**
32-
* **Eliminating irrelevant details and tangents.**
33-
* **Maintain a clear and professional tone.** Do not rewrite the conversation, simply extract the essential elements. Please respond with the summary."
34-
35-
---
36-
37-
**Important Considerations & How to Use This Prompt:**
38-
39-
* **Replace `<INPUT>`:** Replace this placeholder with the actual conversation log text.
40-
* **Context is Key:** The best prompt will depend *entirely* on the nature of your conversation logs. A very technical log might benefit from a more detailed prompt. A casual conversation could use a simpler prompt.
41-
* **Iterate:** Start with a basic prompt and then refine it based on the output you receive. You might need to tweak the emphasis or length instructions.
42-
* **Format Output:** Consider how you want the output formatted. (e.g., bullet points, a short paragraph).
43-
44-
To help me refine the prompt even further, could you tell me:
45-
46-
* What *type* of conversation logs are you dealing with (e.g., customer support, internal project discussions, sales calls)?
47-
* What is the *purpose* of the summary? (e.g., triage, knowledge base, decision-making)?
10+
Remove repetition, transient chatter, and irrelevant details. Keep the result concise but complete enough to continue the work later.

src/repl/conf.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func NewConfigOptions() *ConfigOptions {
7979
co.RegisterOption("chat.memory", BooleanOption, "Load memory.txt from ~/.config/mai and include in context", "false")
8080
co.RegisterOption("chat.replies", BooleanOption, "Include chat replies when building a single prompt", "true")
8181
co.RegisterOption("chat.replythink", BooleanOption, "Include assistant reasoning in stored chat replies", "false")
82-
co.RegisterOption("chat.save", StringOption, "Session save behavior on exit: always, never, or prompt", "prompt")
82+
co.RegisterOption("chat.save", StringOption, "Session save behavior on exit: always, never, prompt, or compact", "never")
8383
co.RegisterOption("chat.system", BooleanOption, "Include chat system messages when building a single prompt", "true")
8484
// Number of most recent messages to include when sending to the LLM (0 = all)
8585
co.RegisterOption("chat.tail", NumberOption, "Number of most recent messages to include when sending to the LLM (0=all)", "0")
@@ -265,6 +265,12 @@ func (c *ConfigOptions) Set(key, value string) error {
265265
}
266266
c.values[key] = value
267267
default: // StringOption or unknown type
268+
if key == "chat.save" {
269+
value = strings.ToLower(strings.TrimSpace(value))
270+
if !isValidChatSaveMode(value) {
271+
return fmt.Errorf("invalid chat.save value: %s (must be one of: always, never, prompt, compact)", value)
272+
}
273+
}
268274
if isReasoningEffortOption(key) {
269275
effort, ok := llm.NormalizeReasoningEffort(value)
270276
if !ok {
@@ -327,6 +333,20 @@ func (c *ConfigOptions) GetAvailableOptions() []string {
327333
return opts
328334
}
329335

336+
func (c *ConfigOptions) displayValue(key string) string {
337+
if c == nil {
338+
return "not set"
339+
}
340+
value := c.Get(key)
341+
if value != "" {
342+
return value
343+
}
344+
if info, exists := c.GetOptionInfo(key); exists && info.Default != "" {
345+
return fmt.Sprintf("default: %s", info.Default)
346+
}
347+
return "not set"
348+
}
349+
330350
// RegisterOptionListener adds a listener function that will be called when an option's value changes
331351
func (c *ConfigOptions) RegisterOptionListener(key string, callback OptionChangeCallback) {
332352
// Create listeners array if it doesn't exist
@@ -375,6 +395,14 @@ func isThinkShowOption(key string) bool {
375395
return false
376396
}
377397

398+
func isValidChatSaveMode(value string) bool {
399+
switch strings.ToLower(strings.TrimSpace(value)) {
400+
case "always", "never", "prompt", "compact":
401+
return true
402+
}
403+
return false
404+
}
405+
378406
// GetOptionType returns the type of a given option
379407
// GetOptionType returns the type of a given option
380408
func (c *ConfigOptions) GetOptionType(option string) OptionType {
@@ -518,7 +546,8 @@ func (r *REPL) handleSetCommand(args []string) (string, error) {
518546
output.WriteString("Available options:\r\n")
519547
for _, option := range r.configOptions.GetAvailableOptions() {
520548
optType := r.configOptions.GetOptionType(option)
521-
fmt.Fprintf(&output, " %-20s %-15s %s\r\n", option, "("+optType+")", r.configOptions.GetOptionDescription(option))
549+
value := r.configOptions.displayValue(option)
550+
fmt.Fprintf(&output, " %-20s = %-15s %-10s %s\r\n", option, value, "("+optType+")", r.configOptions.GetOptionDescription(option))
522551
}
523552
return output.String(), nil
524553
}
@@ -682,6 +711,11 @@ func (r *REPL) handleSetCommand(args []string) (string, error) {
682711
case "chat.system":
683712
_ = r.configOptions.Set("chat.system", value)
684713
return "", nil
714+
case "chat.save":
715+
if err := r.configOptions.Set("chat.save", value); err != nil {
716+
return fmt.Sprintf("Error: %v\r\n", err), nil
717+
}
718+
return fmt.Sprintf("Set chat.save = %s\r\n", r.configOptions.Get("chat.save")), nil
685719
case "chat.format":
686720
valLower := strings.ToLower(value)
687721
if valLower != "plain" && valLower != "labeled" && valLower != "tokens" {

src/repl/repl.go

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2288,29 +2288,23 @@ func (r *REPL) getCurrentModelForProvider() string {
22882288
return r.configOptions.Get("ai.model")
22892289
}
22902290

2291-
// handleCompactCommand processes the /compact command
2292-
// It loads the compact.txt prompt and submits the entire conversation history
2293-
// to the AI, then replaces all messages with the AI's response.
2294-
// The optional extra argument is appended to the compact prompt to let the
2295-
// caller steer the summarization (e.g. "focus on the API changes").
2291+
const compactSaveInstructions = "For saved-session compaction, focus on highlights, decisions, durable facts, open questions, and relevant annotations needed to resume the conversation later."
22962292

2297-
func (r *REPL) handleCompactCommand(extra ...string) error {
2298-
// Check if there are enough messages to compact
2299-
if len(r.messages) < 2 {
2300-
fmt.Print("Not enough messages to compact. Need at least one exchange.\r\n")
2301-
return nil
2293+
// compactMessages submits a conversation snapshot to the compact model and
2294+
// returns the compacted replacement history.
2295+
func (r *REPL) compactMessages(ctx context.Context, messages []llm.Message, extra ...string) ([]llm.Message, error) {
2296+
if len(messages) < 2 {
2297+
return nil, fmt.Errorf("not enough messages to compact")
23022298
}
23032299

2304-
// Try to find the compact prompt using resolvePromptPath
23052300
promptPath, err := r.resolvePromptPath("compact")
23062301
if err != nil {
2307-
return fmt.Errorf("failed to find compact prompt: %v", err)
2302+
return nil, fmt.Errorf("failed to find compact prompt: %v", err)
23082303
}
23092304

2310-
// Load the compact prompt from file
23112305
compactPrompt, err := os.ReadFile(promptPath)
23122306
if err != nil {
2313-
return fmt.Errorf("failed to read compact prompt: %v", err)
2307+
return nil, fmt.Errorf("failed to read compact prompt: %v", err)
23142308
}
23152309

23162310
promptText := string(compactPrompt)
@@ -2322,7 +2316,8 @@ func (r *REPL) handleCompactCommand(extra ...string) error {
23222316
var conversationText strings.Builder
23232317
conversationText.WriteString("# Conversation History\n\n")
23242318

2325-
for i, msg := range r.messagesForLog() {
2319+
for i, msg := range messages {
2320+
msg = r.messageForLog(msg)
23262321
role := formatRole(msg.Role)
23272322
fmt.Fprintf(&conversationText, "## %s %d:\n\n%s\n\n", role, i+1, msg.Content)
23282323
}
@@ -2333,20 +2328,10 @@ func (r *REPL) handleCompactCommand(extra ...string) error {
23332328
Content: promptText + "\n\n" + conversationText.String(),
23342329
}
23352330

2336-
// Save original messages for recovery if needed
2337-
originalMessages := r.messages
2338-
2339-
// Replace messages with just the compact message
2340-
r.messages = []llm.Message{compactMessage}
2341-
2342-
fmt.Print("Compacting conversation...\r\n")
2343-
23442331
// Create client and send message
2345-
client, err := llm.NewLLMClient(r.buildLLMConfigForTask("compact"), r.ctx)
2332+
client, err := llm.NewLLMClient(r.buildLLMConfigForTask("compact"), ctx)
23462333
if err != nil {
2347-
// Restore original messages on error
2348-
r.messages = originalMessages
2349-
return fmt.Errorf("failed to create LLM client: %v", err)
2334+
return nil, fmt.Errorf("failed to create LLM client: %v", err)
23502335
}
23512336

23522337
// Prepare messages for the API
@@ -2359,20 +2344,36 @@ func (r *REPL) handleCompactCommand(extra ...string) error {
23592344
// Send the message to the AI (non-streaming mode for this operation)
23602345
response, err := client.SendMessage(apiMessages, false, nil, nil)
23612346
if err != nil {
2362-
// Restore original messages on error
2363-
r.messages = originalMessages
2364-
return fmt.Errorf("failed to compact conversation: %v", err)
2347+
return nil, fmt.Errorf("failed to compact conversation: %v", err)
23652348
}
23662349

23672350
// Create the assistant response message
23682351
assistantMessage := r.assistantMessageForLog(response)
23692352

2370-
// Replace the conversation with just the compact message and response
2371-
r.messages = []llm.Message{
2372-
llm.Message{Role: "user", Content: "Please provide a compact response to my questions and needs."},
2353+
return []llm.Message{
2354+
{Role: "user", Content: "Please summarize the conversation highlights and relevant annotations."},
23732355
assistantMessage,
2356+
}, nil
2357+
}
2358+
2359+
// handleCompactCommand processes the /chat compact command.
2360+
// It loads the compact prompt and submits the entire conversation history
2361+
// to the AI, then replaces all messages with the compacted response.
2362+
// The optional extra argument is appended to the compact prompt to let the
2363+
// caller steer the summarization.
2364+
func (r *REPL) handleCompactCommand(extra ...string) error {
2365+
if len(r.messages) < 2 {
2366+
fmt.Print("Not enough messages to compact. Need at least one exchange.\r\n")
2367+
return nil
23742368
}
23752369

2370+
snapshot := r.messagesForLog()
2371+
fmt.Print("Compacting conversation...\r\n")
2372+
compacted, err := r.compactMessages(r.ctx, snapshot, extra...)
2373+
if err != nil {
2374+
return err
2375+
}
2376+
r.messages = compacted
23762377
fmt.Print("Conversation compacted successfully.\r\n")
23772378

23782379
return nil

src/repl/repl_chat.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"reflect"
89
"strings"
910
"time"
1011

@@ -16,7 +17,7 @@ func registerChatCommands(r *REPL) {
1617
// Conversation management commands
1718
r.commands["/chat"] = Command{
1819
Name: "/chat",
19-
Description: "Manage conversation (save, load, clear, list, log, undo, compact)",
20+
Description: "Manage conversation (save, load, clear, list, log, undo, compact, bgcompact)",
2021
Handler: func(r *REPL, args []string) (string, error) {
2122
return r.handleChatCommand(args)
2223
},
@@ -93,6 +94,7 @@ func (r *REPL) handleChatCommand(args []string) (string, error) {
9394
output.WriteString(" /chat log - Display full conversation with preserved formatting\r\n")
9495
output.WriteString(" /chat undo [N] - Remove last or Nth message\r\n")
9596
output.WriteString(" /chat compact [text] - Compact conversation; optional text is appended to the compact prompt\r\n")
97+
output.WriteString(" /chat bgcompact [text] - Compact conversation in the background\r\n")
9698
return output.String(), nil
9799
}
98100

@@ -142,6 +144,12 @@ func (r *REPL) handleChatCommand(args []string) (string, error) {
142144
extra = strings.Join(args[2:], " ")
143145
}
144146
return "", r.handleCompactCommand(extra)
147+
case "bgcompact":
148+
extra := ""
149+
if len(args) > 2 {
150+
extra = strings.Join(args[2:], " ")
151+
}
152+
return r.startBackgroundCompact(extra)
145153
case "memory":
146154
// Generate or manage consolidated memory file
147155
if len(args) < 3 || args[2] == "generate" {
@@ -170,6 +178,64 @@ func (r *REPL) handleChatCommand(args []string) (string, error) {
170178
}
171179
return "Usage: /chat memory [generate|show|clear]\r\n", nil
172180
default:
173-
return fmt.Sprintf("Unknown action: %s\r\nAvailable actions: save, load, sessions, clear, list, log, undo, compact\r\n", action), nil
181+
return fmt.Sprintf("Unknown action: %s\r\nAvailable actions: save, load, sessions, clear, list, log, undo, compact, bgcompact\r\n", action), nil
182+
}
183+
}
184+
185+
func (r *REPL) startBackgroundCompact(extra string) (string, error) {
186+
r.mu.Lock()
187+
if r.bgCompactInProgress {
188+
r.mu.Unlock()
189+
return "Background compact already running\r\n", nil
190+
}
191+
r.bgCompactInProgress = true
192+
r.mu.Unlock()
193+
194+
r.requestMu.Lock()
195+
rawSnapshot := append([]llm.Message(nil), r.messages...)
196+
logSnapshot := r.messagesForLog()
197+
r.requestMu.Unlock()
198+
199+
if len(logSnapshot) < 2 {
200+
r.mu.Lock()
201+
r.bgCompactInProgress = false
202+
r.mu.Unlock()
203+
return "Not enough messages to compact. Need at least one exchange.\r\n", nil
204+
}
205+
206+
go r.runBackgroundCompact(rawSnapshot, logSnapshot, extra)
207+
return "Background compact started\r\n", nil
208+
}
209+
210+
func (r *REPL) runBackgroundCompact(rawSnapshot, logSnapshot []llm.Message, extra string) {
211+
defer func() {
212+
r.mu.Lock()
213+
r.bgCompactInProgress = false
214+
r.mu.Unlock()
215+
}()
216+
217+
compacted, err := r.compactMessages(context.Background(), logSnapshot, extra)
218+
if err != nil {
219+
fmt.Fprintf(os.Stderr, "\r\nBackground compact failed: %v\r\n", err)
220+
return
221+
}
222+
223+
r.requestMu.Lock()
224+
defer r.requestMu.Unlock()
225+
226+
if !messagesHavePrefix(r.messages, rawSnapshot) {
227+
fmt.Fprintf(os.Stderr, "\r\nBackground compact skipped: conversation changed before merge\r\n")
228+
return
229+
}
230+
231+
suffix := append([]llm.Message(nil), r.messages[len(rawSnapshot):]...)
232+
r.messages = append(append([]llm.Message(nil), compacted...), suffix...)
233+
fmt.Fprintf(os.Stderr, "\r\nBackground compact completed\r\n")
234+
}
235+
236+
func messagesHavePrefix(messages, prefix []llm.Message) bool {
237+
if len(messages) < len(prefix) {
238+
return false
174239
}
240+
return reflect.DeepEqual(messages[:len(prefix)], prefix)
175241
}

src/repl/repl_core.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ func (r *REPL) cleanup() {
398398
// Auto-save the chat session if history is enabled and messages exist,
399399
// updating the current session or creating a new one if none selected
400400
if r.configOptions.GetBool("repl.history") && len(r.messages) > 0 {
401-
mode := r.configOptions.Get("chat.save")
401+
mode := strings.ToLower(r.configOptions.Get("chat.save"))
402402
if mode != "never" {
403403
var name string
404404
if r.currentSession != "" {
@@ -414,7 +414,13 @@ func (r *REPL) cleanup() {
414414
}
415415
}
416416
fmt.Println("")
417-
if err := r.saveSession(name); err != nil {
417+
var err error
418+
if mode == "compact" {
419+
err = r.saveCompactSession(name)
420+
} else {
421+
err = r.saveSession(name)
422+
}
423+
if err != nil {
418424
fmt.Fprintf(os.Stderr, "Error auto-saving session: %v\n", err)
419425
}
420426
r.currentSession = name

src/repl/repl_input.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ func (r *REPL) handleAtFilePathCompletion(line *strings.Builder, prefix, partial
580580

581581
func (r *REPL) handleChatSubcommandCompletion(line *strings.Builder, partialCmd string) {
582582
// Available chat subcommands
583-
subcommands := []string{"save", "load", "clear", "list", "log", "undo", "compact"}
583+
subcommands := []string{"save", "load", "clear", "list", "log", "undo", "compact", "bgcompact"}
584584

585585
// Filter subcommands by the partial input
586586
var filteredCommands []string

src/repl/repl_types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ type REPL struct {
4848
initialCommand string // Command to execute on startup
4949
quitAfterActions bool // Exit after executing initial command
5050
// Guard to avoid recursive followup execution
51-
followupInProgress bool
51+
followupInProgress bool
52+
bgCompactInProgress bool
5253
// Callback to stop demo animation when first token is received
5354
stopDemoCallback func()
5455
wmcpProcess *exec.Cmd

0 commit comments

Comments
 (0)