Merge pull request #3 from dimonb/codex/compaction-daily-summary
Add compaction summaries and memory docs
This commit is contained in:
commit
0d222d8786
6 changed files with 832 additions and 35 deletions
16
README.md
16
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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
65
pkg/agent/memory_test.go
Normal file
65
pkg/agent/memory_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
315
pkg/agent/summarization_prompts.go
Normal file
315
pkg/agent/summarization_prompts.go
Normal 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()
|
||||
}
|
||||
190
pkg/agent/summarization_test.go
Normal file
190
pkg/agent/summarization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue