Merge pull request #3 from dimonb/codex/compaction-daily-summary

Add compaction summaries and memory docs
This commit is contained in:
DimonB 2026-03-08 21:36:03 +02:00 committed by GitHub
commit 0d222d8786
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 832 additions and 35 deletions

View file

@ -765,8 +765,12 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
``` ```
~/.picoclaw/workspace/ ~/.picoclaw/workspace/
├── sessions/ # Conversation sessions and history ├── sessions/ # Conversation sessions, summaries, and recent history
├── memory/ # Long-term memory (MEMORY.md) ├── 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.) ├── state/ # Persistent state (last channel, etc.)
├── cron/ # Scheduled jobs database ├── cron/ # Scheduled jobs database
├── skills/ # Custom skills ├── skills/ # Custom skills
@ -778,6 +782,14 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
└── USER.md # User preferences └── 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 ### Skill Sources
By default, skills are loaded from: By default, skills are loaded from:

View file

@ -1379,7 +1379,7 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
go func() { go func() {
defer al.summarizing.Delete(summarizeKey) defer al.summarizing.Delete(summarizeKey)
logger.Debug("Memory threshold reached. Optimizing conversation history...") 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. // 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) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() defer cancel()
@ -1570,27 +1573,18 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s1, _ := al.summarizeBatch(ctx, agent, part1, "") s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "") s2, _ := al.summarizeBatch(ctx, agent, part2, "")
mergedSummary, err := al.mergeRunningSummaries(
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(
ctx, ctx,
[]providers.Message{{Role: "user", Content: mergePrompt}}, agent,
nil, summary,
agent.Model, []string{s1, s2},
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
) )
if err == nil { if err == nil {
finalSummary = resp.Content finalSummary = mergedSummary
} else { } else {
finalSummary = s1 + " " + s2 finalSummary = strings.TrimSpace(
strings.Join([]string{summary, s1, s2}, "\n\n"),
)
} }
} else { } else {
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
@ -1601,6 +1595,53 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
} }
if finalSummary != "" { 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.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, 4) agent.Sessions.TruncateHistory(sessionKey, 4)
agent.Sessions.Save(sessionKey) agent.Sessions.Save(sessionKey)
@ -1614,20 +1655,7 @@ func (al *AgentLoop) summarizeBatch(
batch []providers.Message, batch []providers.Message,
existingSummary string, existingSummary string,
) (string, error) { ) (string, error) {
var sb strings.Builder prompt := buildRunningSummaryPrompt(existingSummary, batch)
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()
response, err := agent.Provider.Chat( response, err := agent.Provider.Chat(
ctx, ctx,
@ -1646,6 +1674,155 @@ func (al *AgentLoop) summarizeBatch(
return response.Content, nil 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. // 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 // Uses a safe heuristic of 2.5 characters per token to account for CJK and other
// overheads better than the previous 3 chars/token. // overheads better than the previous 3 chars/token.

View file

@ -105,6 +105,44 @@ func (ms *MemoryStore) AppendToday(content string) error {
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) 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. // GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator. // Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string { func (ms *MemoryStore) GetRecentDailyNotes(days int) string {

65
pkg/agent/memory_test.go Normal file
View file

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

View file

@ -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(`<task>
Update the running conversation summary for future context injection.
</task>
<instructions>
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.
</instructions>
<format>
Return Markdown with exactly these sections:
## Key Context
## Decisions
## Open Loops
## Preferences / Constraints
Use short bullet points.
If a section is empty, write "- none".
</format>
<existing_summary>
%s
</existing_summary>
<conversation>
%s
</conversation>`, existingSummary, formatConversationMessages(batch))
}
func buildRunningSummaryMergePrompt(
existingSummary string,
partialSummaries []string,
) string {
if strings.TrimSpace(existingSummary) == "" {
existingSummary = "(none)"
}
var sb strings.Builder
sb.WriteString(`<task>
Merge the existing running summary and the new partial summaries into one updated running summary.
</task>
<instructions>
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.
</instructions>
<format>
Return Markdown with exactly these sections:
## Key Context
## Decisions
## Open Loops
## Preferences / Constraints
Use short bullet points.
If a section is empty, write "- none".
</format>
<existing_summary>
`)
sb.WriteString(existingSummary)
sb.WriteString(`
</existing_summary>
<partial_summaries>
`)
for i, summary := range partialSummaries {
if strings.TrimSpace(summary) == "" {
continue
}
fmt.Fprintf(&sb, "<summary index=\"%d\">\n%s\n</summary>\n", i+1, summary)
}
sb.WriteString(`</partial_summaries>`)
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(`<task>
Write a detailed compaction memory note for this conversation segment.
</task>
<metadata>
<session_key>%s</session_key>
<channel>%s</channel>
<chat_id>%s</chat_id>
<source_messages>%d</source_messages>
<oversized_messages_omitted>%s</oversized_messages_omitted>
</metadata>
<instructions>
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.
</instructions>
<format>
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".
</format>
<running_summary>
%s
</running_summary>
<conversation>
%s
</conversation>`,
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(`<task>
Merge these detailed compaction notes into one cohesive daily memory entry.
</task>
<metadata>
`)
fmt.Fprintf(&sb, "<session_key>%s</session_key>\n", meta.SessionKey)
fmt.Fprintf(&sb, "<channel>%s</channel>\n", meta.Channel)
fmt.Fprintf(&sb, "<chat_id>%s</chat_id>\n", meta.ChatID)
fmt.Fprintf(&sb, "<source_messages>%d</source_messages>\n", meta.SourceMessages)
fmt.Fprintf(&sb, "<oversized_messages_omitted>%s</oversized_messages_omitted>\n", omitted)
sb.WriteString(`</metadata>
<instructions>
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.
</instructions>
<format>
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".
</format>
<running_summary>
`)
sb.WriteString(runningSummary)
sb.WriteString(`
</running_summary>
<partial_notes>
`)
for i, note := range partialNotes {
if strings.TrimSpace(note) == "" {
continue
}
fmt.Fprintf(&sb, "<note index=\"%d\">\n%s\n</note>\n", i+1, note)
}
sb.WriteString(`</partial_notes>`)
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()
}

View file

@ -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], "<channel>telegram</channel>") {
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, "<existing_summary>") {
t.Fatalf("prompt missing existing summary block: %q", prompt)
}
if !strings.Contains(prompt, "<summary index=\"2\">") {
t.Fatalf("prompt missing indexed summary block: %q", prompt)
}
}