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)
+ }
+}