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/
|
~/.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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
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