From c55ec64bf2c3d4cad050e1c2dfe764f89148c427 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Sun, 8 Mar 2026 21:35:01 +0200 Subject: [PATCH] Add compaction summaries and memory docs --- README.md | 16 +- pkg/agent/loop.go | 243 +++++++++++++++++++--- pkg/agent/memory.go | 38 ++++ pkg/agent/memory_test.go | 65 ++++++ pkg/agent/summarization_prompts.go | 315 +++++++++++++++++++++++++++++ pkg/agent/summarization_test.go | 190 +++++++++++++++++ 6 files changed, 832 insertions(+), 35 deletions(-) create mode 100644 pkg/agent/memory_test.go create mode 100644 pkg/agent/summarization_prompts.go create mode 100644 pkg/agent/summarization_test.go diff --git a/README.md b/README.md index 5cf9f6143..a072a002b 100644 --- a/README.md +++ b/README.md @@ -765,8 +765,12 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ``` ~/.picoclaw/workspace/ -├── sessions/ # Conversation sessions and history -├── memory/ # Long-term memory (MEMORY.md) +├── sessions/ # Conversation sessions, summaries, and recent history +├── memory/ +│ ├── MEMORY.md # Long-term memory +│ └── YYYYMM/ +│ ├── YYYYMMDD.md # Daily notes +│ └── YYYYMMDD-HHMMSS.compactions.md # Detailed auto-generated compaction notes ├── state/ # Persistent state (last channel, etc.) ├── cron/ # Scheduled jobs database ├── skills/ # Custom skills @@ -778,6 +782,14 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa └── USER.md # User preferences ``` +### Memory And Conversation Compaction + +- `memory/MEMORY.md` stores durable long-term facts and preferences. +- `memory/YYYYMM/YYYYMMDD.md` stores daily notes. +- When a session grows beyond the summarization threshold, PicoClaw keeps a short structured running summary for future prompt context and truncates the old session history to the most recent turns. +- At the same time, PicoClaw writes a detailed record of the compacted conversation segment to `memory/YYYYMM/YYYYMMDD-HHMMSS.compactions.md`. +- These `.compactions.md` files are for audit trail and later review. They are not injected back into the default memory context, so they do not bloat the active prompt. + ### Skill Sources By default, skills are loaded from: diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 9d304271f..1ff50fcc2 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1379,7 +1379,7 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c go func() { defer al.summarizing.Delete(summarizeKey) logger.Debug("Memory threshold reached. Optimizing conversation history...") - al.summarizeSession(agent, sessionKey) + al.summarizeSession(agent, sessionKey, channel, chatID) }() } } @@ -1526,7 +1526,10 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { } // summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { +func (al *AgentLoop) summarizeSession( + agent *AgentInstance, + sessionKey, channel, chatID string, +) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -1570,27 +1573,18 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { s1, _ := al.summarizeBatch(ctx, agent, part1, "") s2, _ := al.summarizeBatch(ctx, agent, part2, "") - - mergePrompt := fmt.Sprintf( - "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", - s1, - s2, - ) - resp, err := agent.Provider.Chat( + mergedSummary, err := al.mergeRunningSummaries( ctx, - []providers.Message{{Role: "user", Content: mergePrompt}}, - nil, - agent.Model, - map[string]any{ - "max_tokens": 1024, - "temperature": 0.3, - "prompt_cache_key": agent.ID, - }, + agent, + summary, + []string{s1, s2}, ) if err == nil { - finalSummary = resp.Content + finalSummary = mergedSummary } else { - finalSummary = s1 + " " + s2 + finalSummary = strings.TrimSpace( + strings.Join([]string{summary, s1, s2}, "\n\n"), + ) } } else { finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) @@ -1601,6 +1595,53 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { } if finalSummary != "" { + meta := compactionNoteMetadata{ + SessionKey: sessionKey, + Channel: channel, + ChatID: chatID, + SourceMessages: len(validMessages), + OmittedMessages: omitted, + } + + noteTimestamp := time.Now() + detailedNote, detailErr := al.generateDetailedCompactionNote( + ctx, + agent, + validMessages, + finalSummary, + meta, + ) + if detailErr != nil { + logger.WarnCF("agent", "Detailed compaction summary generation failed", map[string]any{ + "session_key": sessionKey, + "error": detailErr.Error(), + }) + } + + if agent.ContextBuilder != nil && agent.ContextBuilder.memory != nil { + content := buildCompactionFileContent( + noteTimestamp, + meta, + finalSummary, + detailedNote, + ) + path, err := agent.ContextBuilder.memory.WriteCompactionSummary( + noteTimestamp, + content, + ) + if err != nil { + logger.WarnCF("agent", "Failed to persist compaction summary", map[string]any{ + "session_key": sessionKey, + "error": err.Error(), + }) + } else { + logger.DebugCF("agent", "Compaction summary persisted", map[string]any{ + "session_key": sessionKey, + "path": path, + }) + } + } + agent.Sessions.SetSummary(sessionKey, finalSummary) agent.Sessions.TruncateHistory(sessionKey, 4) agent.Sessions.Save(sessionKey) @@ -1614,20 +1655,7 @@ func (al *AgentLoop) summarizeBatch( batch []providers.Message, existingSummary string, ) (string, error) { - var sb strings.Builder - sb.WriteString( - "Provide a concise summary of this conversation segment, preserving core context and key points.\n", - ) - if existingSummary != "" { - sb.WriteString("Existing context: ") - sb.WriteString(existingSummary) - sb.WriteString("\n") - } - sb.WriteString("\nCONVERSATION:\n") - for _, m := range batch { - fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) - } - prompt := sb.String() + prompt := buildRunningSummaryPrompt(existingSummary, batch) response, err := agent.Provider.Chat( ctx, @@ -1646,6 +1674,155 @@ func (al *AgentLoop) summarizeBatch( return response.Content, nil } +func (al *AgentLoop) mergeRunningSummaries( + ctx context.Context, + agent *AgentInstance, + existingSummary string, + partialSummaries []string, +) (string, error) { + prompt := buildRunningSummaryMergePrompt(existingSummary, partialSummaries) + + response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.2, + "prompt_cache_key": agent.ID, + }, + ) + if err != nil { + return "", err + } + return response.Content, nil +} + +func (al *AgentLoop) summarizeDetailedCompactionBatch( + ctx context.Context, + agent *AgentInstance, + batch []providers.Message, + runningSummary string, + meta compactionNoteMetadata, +) (string, error) { + prompt := buildDetailedCompactionPrompt(runningSummary, batch, meta) + + response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 2048, + "temperature": 0.2, + "prompt_cache_key": agent.ID, + }, + ) + if err != nil { + return "", err + } + return response.Content, nil +} + +func (al *AgentLoop) mergeDetailedCompactionNotes( + ctx context.Context, + agent *AgentInstance, + runningSummary string, + partialNotes []string, + meta compactionNoteMetadata, +) (string, error) { + prompt := buildDetailedCompactionMergePrompt( + runningSummary, + partialNotes, + meta, + ) + + response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 2048, + "temperature": 0.2, + "prompt_cache_key": agent.ID, + }, + ) + if err != nil { + return "", err + } + return response.Content, nil +} + +func (al *AgentLoop) generateDetailedCompactionNote( + ctx context.Context, + agent *AgentInstance, + validMessages []providers.Message, + runningSummary string, + meta compactionNoteMetadata, +) (string, error) { + if len(validMessages) == 0 { + return "", nil + } + + if len(validMessages) <= 10 { + return al.summarizeDetailedCompactionBatch( + ctx, + agent, + validMessages, + runningSummary, + meta, + ) + } + + mid := len(validMessages) / 2 + parts := [][]providers.Message{ + validMessages[:mid], + validMessages[mid:], + } + + notes := make([]string, 0, len(parts)) + var firstErr error + for _, part := range parts { + note, err := al.summarizeDetailedCompactionBatch( + ctx, + agent, + part, + runningSummary, + meta, + ) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if strings.TrimSpace(note) != "" { + notes = append(notes, note) + } + } + + switch len(notes) { + case 0: + return "", firstErr + case 1: + return notes[0], nil + default: + merged, err := al.mergeDetailedCompactionNotes( + ctx, + agent, + runningSummary, + notes, + meta, + ) + if err != nil { + return strings.Join(notes, "\n\n"), err + } + return merged, nil + } +} + // estimateTokens estimates the number of tokens in a message list. // Uses a safe heuristic of 2.5 characters per token to account for CJK and other // overheads better than the previous 3 chars/token. diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 01e682f3b..d9c8d4d62 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -105,6 +105,44 @@ func (ms *MemoryStore) AppendToday(content string) error { return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) } +// WriteCompactionSummary writes a timestamped compaction summary file under +// memory/YYYYMM/YYYYMMDD-HHMMSS.compactions.md. If a file with the same second +// already exists, it appends a numeric suffix to avoid collisions. +func (ms *MemoryStore) WriteCompactionSummary( + timestamp time.Time, + content string, +) (string, error) { + monthDir := filepath.Join(ms.memoryDir, timestamp.Format("200601")) + if err := os.MkdirAll(monthDir, 0o755); err != nil { + return "", err + } + + baseName := timestamp.Format("20060102-150405") + filePath := filepath.Join(monthDir, baseName+".compactions.md") + + for i := 2; ; i++ { + if _, err := os.Stat(filePath); os.IsNotExist(err) { + break + } else if err != nil { + return "", err + } + filePath = filepath.Join( + monthDir, + fmt.Sprintf("%s-%02d.compactions.md", baseName, i), + ) + } + + trimmed := strings.TrimSpace(content) + if trimmed != "" { + trimmed += "\n" + } + + if err := fileutil.WriteFileAtomic(filePath, []byte(trimmed), 0o600); err != nil { + return "", err + } + return filePath, nil +} + // GetRecentDailyNotes returns daily notes from the last N days. // Contents are joined with "---" separator. func (ms *MemoryStore) GetRecentDailyNotes(days int) string { diff --git a/pkg/agent/memory_test.go b/pkg/agent/memory_test.go new file mode 100644 index 000000000..de6a0107c --- /dev/null +++ b/pkg/agent/memory_test.go @@ -0,0 +1,65 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWriteCompactionSummaryCreatesTimestampedFile(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + store := NewMemoryStore(workspace) + timestamp := time.Date(2026, time.March, 8, 14, 5, 9, 0, time.Local) + + path, err := store.WriteCompactionSummary(timestamp, "# Summary\n\nBody") + if err != nil { + t.Fatalf("WriteCompactionSummary failed: %v", err) + } + + expected := filepath.Join( + workspace, + "memory", + "202603", + "20260308-140509.compactions.md", + ) + if path != expected { + t.Fatalf("path = %q, want %q", path, expected) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(data) != "# Summary\n\nBody\n" { + t.Fatalf("unexpected file contents: %q", string(data)) + } +} + +func TestGetRecentDailyNotesIgnoresCompactionFiles(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + store := NewMemoryStore(workspace) + if err := store.AppendToday("## Daily\n\nKeep this in prompt context."); err != nil { + t.Fatalf("AppendToday failed: %v", err) + } + + if _, err := store.WriteCompactionSummary( + time.Now(), + "# Compaction Summary\n\nDo not inject this into the prompt.", + ); err != nil { + t.Fatalf("WriteCompactionSummary failed: %v", err) + } + + notes := store.GetRecentDailyNotes(1) + if !strings.Contains(notes, "Keep this in prompt context.") { + t.Fatalf("daily note missing from recent notes: %q", notes) + } + if strings.Contains(notes, "Do not inject this into the prompt.") { + t.Fatalf("compaction note leaked into recent daily notes: %q", notes) + } +} diff --git a/pkg/agent/summarization_prompts.go b/pkg/agent/summarization_prompts.go new file mode 100644 index 000000000..e337adaad --- /dev/null +++ b/pkg/agent/summarization_prompts.go @@ -0,0 +1,315 @@ +package agent + +import ( + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +type compactionNoteMetadata struct { + SessionKey string + Channel string + ChatID string + SourceMessages int + OmittedMessages bool +} + +func formatConversationMessages(batch []providers.Message) string { + var sb strings.Builder + for _, m := range batch { + fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) + } + return strings.TrimSpace(sb.String()) +} + +func buildRunningSummaryPrompt( + existingSummary string, + batch []providers.Message, +) string { + if strings.TrimSpace(existingSummary) == "" { + existingSummary = "(none)" + } + + return fmt.Sprintf(` +Update the running conversation summary for future context injection. + + + +Preserve only information likely to matter in future turns. +Prioritize: +- decisions and commitments +- unresolved questions or follow-ups +- user preferences, constraints, and working style +- important corrections or changed assumptions +- referenced files, URLs, identifiers, versions, and named entities +- action items with owner and timeframe if present + +Omit small talk, repetition, exploratory dead ends, and wording that does not change future behavior. +If newer statements conflict with older ones, prefer the newer statement and note the change briefly. +Do not invent facts. +Write in the dominant language of the conversation. +Keep the result under 180 words. + + + +Return Markdown with exactly these sections: +## Key Context +## Decisions +## Open Loops +## Preferences / Constraints + +Use short bullet points. +If a section is empty, write "- none". + + + +%s + + + +%s +`, existingSummary, formatConversationMessages(batch)) +} + +func buildRunningSummaryMergePrompt( + existingSummary string, + partialSummaries []string, +) string { + if strings.TrimSpace(existingSummary) == "" { + existingSummary = "(none)" + } + + var sb strings.Builder + sb.WriteString(` +Merge the existing running summary and the new partial summaries into one updated running summary. + + + +Keep the exact section structure below. +Deduplicate aggressively. +Preserve unresolved items until they are resolved. +Prefer newer information when facts conflict. +Keep only future-relevant context. +Do not invent facts. +Keep the result under 180 words. + + + +Return Markdown with exactly these sections: +## Key Context +## Decisions +## Open Loops +## Preferences / Constraints + +Use short bullet points. +If a section is empty, write "- none". + + + +`) + sb.WriteString(existingSummary) + sb.WriteString(` + + + +`) + for i, summary := range partialSummaries { + if strings.TrimSpace(summary) == "" { + continue + } + fmt.Fprintf(&sb, "\n%s\n\n", i+1, summary) + } + sb.WriteString(``) + return sb.String() +} + +func buildDetailedCompactionPrompt( + runningSummary string, + batch []providers.Message, + meta compactionNoteMetadata, +) string { + if strings.TrimSpace(runningSummary) == "" { + runningSummary = "(none)" + } + + omitted := "no" + if meta.OmittedMessages { + omitted = "yes" + } + + return fmt.Sprintf(` +Write a detailed compaction memory note for this conversation segment. + + + +%s +%s +%s +%d +%s + + + +Create a faithful, high-signal summary of this segment. +Include: +- what the user wanted +- what was done or decided +- unresolved follow-ups +- notable files, commands, paths, URLs, entities, versions, and deadlines +- stable preferences or working style signals +- important corrections and changes of plan + +Separate confirmed facts from tentative ideas when needed. +Omit filler and repetition. +Do not invent details. +Write in the dominant language of the conversation. +Target 250-500 words. + + + +Return Markdown with exactly these sections: +## Session +## What Happened +## Decisions +## Action Items +## Open Questions +## Artifacts Mentioned +## Preferences / Working Style + +Use bullet points when helpful. +If a section is empty, write "- none". + + + +%s + + + +%s +`, + meta.SessionKey, + meta.Channel, + meta.ChatID, + meta.SourceMessages, + omitted, + runningSummary, + formatConversationMessages(batch), + ) +} + +func buildDetailedCompactionMergePrompt( + runningSummary string, + partialNotes []string, + meta compactionNoteMetadata, +) string { + if strings.TrimSpace(runningSummary) == "" { + runningSummary = "(none)" + } + + omitted := "no" + if meta.OmittedMessages { + omitted = "yes" + } + + var sb strings.Builder + sb.WriteString(` +Merge these detailed compaction notes into one cohesive daily memory entry. + + + +`) + fmt.Fprintf(&sb, "%s\n", meta.SessionKey) + fmt.Fprintf(&sb, "%s\n", meta.Channel) + fmt.Fprintf(&sb, "%s\n", meta.ChatID) + fmt.Fprintf(&sb, "%d\n", meta.SourceMessages) + fmt.Fprintf(&sb, "%s\n", omitted) + sb.WriteString(` + + +Preserve important details, deduplicate aggressively, and prefer newer facts when notes conflict. +Maintain the exact section structure below. +Keep enough detail for a future reader to reconstruct the work without replaying the full conversation. +Do not invent facts. +Write in the dominant language of the source notes. + + + +Return Markdown with exactly these sections: +## Session +## What Happened +## Decisions +## Action Items +## Open Questions +## Artifacts Mentioned +## Preferences / Working Style + +Use bullet points when helpful. +If a section is empty, write "- none". + + + +`) + sb.WriteString(runningSummary) + sb.WriteString(` + + + +`) + for i, note := range partialNotes { + if strings.TrimSpace(note) == "" { + continue + } + fmt.Fprintf(&sb, "\n%s\n\n", i+1, note) + } + sb.WriteString(``) + return sb.String() +} + +func buildCompactionFileContent( + timestamp time.Time, + meta compactionNoteMetadata, + runningSummary string, + detail string, +) string { + omitted := "no" + if meta.OmittedMessages { + omitted = "yes" + } + + channel := meta.Channel + if channel == "" { + channel = "n/a" + } + chatID := meta.ChatID + if chatID == "" { + chatID = "n/a" + } + + var sb strings.Builder + sb.WriteString("# Compaction Summary ") + sb.WriteString(timestamp.Format("2006-01-02 15:04:05")) + sb.WriteString("\n\n") + fmt.Fprintf(&sb, "- Session: `%s`\n", meta.SessionKey) + fmt.Fprintf(&sb, "- Channel: `%s`\n", channel) + fmt.Fprintf(&sb, "- Chat ID: `%s`\n", chatID) + fmt.Fprintf(&sb, "- Source messages summarized: %d\n", meta.SourceMessages) + fmt.Fprintf(&sb, "- Oversized messages omitted: %s\n", omitted) + + if strings.TrimSpace(runningSummary) != "" { + sb.WriteString("\n## Running Summary Snapshot\n\n") + sb.WriteString(strings.TrimSpace(runningSummary)) + sb.WriteString("\n") + } + + sb.WriteString("\n---\n\n") + if strings.TrimSpace(detail) != "" { + sb.WriteString(strings.TrimSpace(detail)) + sb.WriteString("\n") + } else { + sb.WriteString("## Detailed Summary\n\n- unavailable; see running summary snapshot above.\n") + } + + return sb.String() +} diff --git a/pkg/agent/summarization_test.go b/pkg/agent/summarization_test.go new file mode 100644 index 000000000..88b85b582 --- /dev/null +++ b/pkg/agent/summarization_test.go @@ -0,0 +1,190 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type compactionSummaryMockProvider struct { + prompts []string +} + +func (m *compactionSummaryMockProvider) Chat( + _ context.Context, + messages []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + prompt := messages[0].Content + m.prompts = append(m.prompts, prompt) + + switch { + case strings.Contains(prompt, "Update the running conversation summary for future context injection."): + return &providers.LLMResponse{Content: `## Key Context +- user is working on compaction summaries +## Decisions +- store detailed compaction notes in timestamped files +## Open Loops +- none +## Preferences / Constraints +- prefer separate files over prompt inflation`}, nil + case strings.Contains(prompt, "Write a detailed compaction memory note for this conversation segment."): + return &providers.LLMResponse{Content: `## Session +- focus: compaction flow +## What Happened +- reviewed the summary flow and decided to persist a detailed note +## Decisions +- use timestamped compaction files +## Action Items +- none +## Open Questions +- none +## Artifacts Mentioned +- memory/YYYYMM/YYYYMMDD-HHMMSS.compactions.md +## Preferences / Working Style +- keep the context summary short`}, nil + case strings.Contains(prompt, "Merge the existing running summary and the new partial summaries into one updated running summary."): + return &providers.LLMResponse{Content: `## Key Context +- merged +## Decisions +- merged +## Open Loops +- none +## Preferences / Constraints +- none`}, nil + case strings.Contains(prompt, "Merge these detailed compaction notes into one cohesive daily memory entry."): + return &providers.LLMResponse{Content: `## Session +- merged +## What Happened +- merged +## Decisions +- merged +## Action Items +- none +## Open Questions +- none +## Artifacts Mentioned +- none +## Preferences / Working Style +- none`}, nil + default: + return &providers.LLMResponse{Content: "unexpected prompt"}, nil + } +} + +func (m *compactionSummaryMockProvider) GetDefaultModel() string { + return "mock-model" +} + +func TestSummarizeSessionWritesDetailedCompactionFile(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + provider := &compactionSummaryMockProvider{} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + sessionKey := "session-1" + history := []providers.Message{ + {Role: "user", Content: "Need a better compaction summary flow."}, + {Role: "assistant", Content: "Current prompt is too generic."}, + {Role: "user", Content: "Write the detailed summary into a separate file."}, + {Role: "assistant", Content: "We should keep the running summary short."}, + {Role: "user", Content: "Add timestamps to the compaction filename."}, + {Role: "assistant", Content: "Use hours, minutes, and seconds."}, + } + for _, msg := range history { + agent.Sessions.AddFullMessage(sessionKey, msg) + } + + al.summarizeSession(agent, sessionKey, "telegram", "chat-42") + + summary := agent.Sessions.GetSummary(sessionKey) + if !strings.Contains(summary, "## Key Context") { + t.Fatalf("summary missing structured heading: %q", summary) + } + + finalHistory := agent.Sessions.GetHistory(sessionKey) + if len(finalHistory) != 4 { + t.Fatalf("history len = %d, want 4", len(finalHistory)) + } + + files, err := filepath.Glob( + filepath.Join(workspace, "memory", "*", "*.compactions.md"), + ) + if err != nil { + t.Fatalf("Glob failed: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected one compaction file, got %d", len(files)) + } + + data, err := os.ReadFile(files[0]) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + content := string(data) + if !strings.Contains(content, "Session: `session-1`") { + t.Fatalf("compaction file missing session metadata: %q", content) + } + if !strings.Contains(content, "Channel: `telegram`") { + t.Fatalf("compaction file missing channel metadata: %q", content) + } + if !strings.Contains(content, "## What Happened") { + t.Fatalf("compaction file missing detailed note body: %q", content) + } + + if len(provider.prompts) != 2 { + t.Fatalf("expected 2 provider calls, got %d", len(provider.prompts)) + } + if !strings.Contains(provider.prompts[0], "Keep the result under 180 words.") { + t.Fatalf("running summary prompt missing compactness rule: %q", provider.prompts[0]) + } + if !strings.Contains(provider.prompts[1], "Target 250-500 words.") { + t.Fatalf("detailed compaction prompt missing target length: %q", provider.prompts[1]) + } + if !strings.Contains(provider.prompts[1], "telegram") { + t.Fatalf("detailed compaction prompt missing channel metadata: %q", provider.prompts[1]) + } +} + +func TestBuildRunningSummaryMergePromptIncludesConflictRules(t *testing.T) { + t.Parallel() + + prompt := buildRunningSummaryMergePrompt( + "## Key Context\n- prior summary", + []string{"## Key Context\n- first", "## Key Context\n- second"}, + ) + + if !strings.Contains(prompt, "Prefer newer information when facts conflict.") { + t.Fatalf("prompt missing conflict rule: %q", prompt) + } + if !strings.Contains(prompt, "") { + t.Fatalf("prompt missing existing summary block: %q", prompt) + } + if !strings.Contains(prompt, "") { + t.Fatalf("prompt missing indexed summary block: %q", prompt) + } +}