Files
picoclaw/pkg/seahorse/short_compaction.go
T
Liu Yuan 15a70ac45c feat(seahorse): implement short-term memory engine (LCM) (#2285)
* feat(seahorse): implement short-term memory engine of seahorse

Add pkg/seahorse/ module implementing a SQLite-backed DAG-based summary
hierarchy for context management, ported from lossless-claw's LCM design:

- types.go + short_constants.go: core types (Message, Summary, Conversation,
  ContextItem) and configuration constants (fanout, token targets, thresholds)
- migration.go: idempotent DB schema with FTS5 trigram tokenizer for CJK
- store.go: full SQLite CRUD (conversations, messages, summaries DAG,
  context_items with ordinal gap numbering, FTS5 search)
- short_engine.go: Engine lifecycle (NewEngine, Ingest, Assemble, Compact),
  session pattern filtering (ignore/stateless glob→regex compilation),
  per-session mutex via sync.Map
- short_assembler.go: budget-aware context assembly with fresh tail protection
  (32 messages), oldest-first eviction, summary XML formatting, RebuildContextItems
- short_compaction.go: leaf compaction (messages→summary) and condensed
  compaction (summaries→higher-level summary), 3-level LLM escalation,
  CompactUntilUnder for emergency overflow
- short_retrieval.go: lookupByID, FTS5/LIKE search, recursive expand with
  token cap
- context_seahorse.go: agent.ContextManager adapter, registered as "seahorse",
  provider↔seahorse message type conversion (ToolCalls, tool_result)

* fix(seahorse): correct 3 adapter bugs in context management

- TokenCount: use full message (Content+ToolCalls+Media) instead of Content-only
- Empty Content: rebuild Content from tool_result Parts when stored empty
- Duplicate summaries: summaries only in Summary field, not in History messages
- Grep: fix SearchResult.Snippet→Content for summaries
- Schema: fix FTS5 SQL uses VIRTUAL TABLE not TEMP TABLE
- TestFTS5SQLConstants: verify FTS5 SQL syntax correctness
- Test: fix flaky TestCompactLeaf

* fix(agent): ingest steering messages into seahorse SQLite

Steering messages were only persisted to session JSONL but not ingested
into seahorse SQLite, causing them to be missing from context assembly.

Added `ts.ingestMessage(turnCtx, al, pm)` call in the steering message
injection block alongside the existing JSONL persistence.

Test: TestSeahorseSteeringMessageIngested verifies steering messages
appear in seahorse SQLite DB after being processed.

* fix(seahorse): address 3 blocking bugs from code review

- Fix resequenceContextItemsTx scan error handling (store.go:850)
  Changed `return err` to `return scanErr` to properly propagate scan errors
  instead of returning nil (which silently corrupts data)

- Fix sql.NullString for INTEGER column (store.go:847)
  Changed `mid` from sql.NullString to sql.NullInt64 since message_id
  is INTEGER in schema. Removed unnecessary strconv.ParseInt call.

- Fix compactCondensed fallback deleting non-candidate items
  Added ReplaceContextItemsWithSummary method for per-item deletion
  when candidates are not contiguous in ordinal space.
  Optimized to use range deletion when candidates are consecutive.

* fix(seahorse): pass Budget to Compact for correct condensed threshold

Issue #4 from PR review: When Budget was not passed to seahorse.Compact,
it defaulted to `tokensBefore * 0.75`, making `tokensBefore > budget`
always true and causing condensed compaction to trigger unnecessarily.

Changes:
- context_seahorse.go: Forward Budget from CompactRequest to CompactInput
- loop.go: Pass Budget (ContextWindow) in all 3 Compact calls
- Add test verifying condensed is skipped when tokens < threshold
- Fix lint issues in store.go and store_test.go

* fix(seahorse): add mutex for assembler lazy initialization

Issue #5 from PR review: The check-then-create pattern for e.assembler
was a data race when multiple goroutines called Assemble() concurrently:
    if e.assembler == nil {
        e.assembler = &Assembler{...}
    }

Changes:
- Add assemblerMu sync.Mutex to Engine struct
- Add initAssemblerOnce() using double-checked locking (same pattern as initCompactionOnce)
- Add TestAssemblerLazyInitRace to verify thread-safety

* fix(seahorse): handle non-consecutive depths in selectShallowestCondensationCandidate

Issue #8 from PR review: the loop iterated depth 0, 1, 2... assuming
consecutive keys, but break when key was missing caused deeper depths
to never be checked.

Fix: collect all existing depth keys, sort, then iterate in order.

* fix(seahorse): wrap DeleteMessagesAfterID and appendContextItems in transactions

- DeleteMessagesAfterID: wrap all DELETE operations in a transaction for
  atomicity, remove redundant manual FTS delete (handled by trigger)
- appendContextItems: use transaction to fix read-then-write race condition
- Add GetMaxOrdinalTx and resolveItemTokenCountTx for transaction-scoped queries
- Remove unused resolveItemTokenCount function

Fixes PR review issues 6 and 7.

* fix(seahorse): derive readable content from Parts and cap CompactUntilUnder iterations

- Derive readable content from MessageParts in AddMessageWithParts so
  FTS5 indexing and summary formatting can access tool call information
- formatMessagesForSummary and truncateSummary now fall back to Parts
  when Content is empty, fixing blank summaries for Part-based messages
- Add MaxCompactIterations (20) to prevent CompactUntilUnder infinite
  loops; exceeded iterations are logged as warnings
2026-04-05 09:05:16 +08:00

899 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package seahorse
import (
"context"
"fmt"
"sort"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tokenizer"
)
// CompactInput controls compaction behavior.
type CompactInput struct {
Budget *int // Token budget override
Force bool // Force compaction even if below threshold
}
// CompactResult describes what was compacted.
type CompactResult struct {
SummariesCreated []string `json:"summariesCreated"`
TokensSaved int `json:"tokensSaved"`
LeafSummaries int `json:"leafSummaries"`
CondensedSummaries int `json:"condensedSummaries"`
}
// NeedsCompaction returns true if context tokens >= ContextThreshold × contextWindow.
func (e *CompactionEngine) NeedsCompaction(ctx context.Context, convID int64, contextWindow int) (bool, error) {
tokens, err := e.store.GetContextTokenCount(ctx, convID)
if err != nil {
return false, fmt.Errorf("get token count: %w", err)
}
threshold := int(float64(contextWindow) * ContextThreshold)
return tokens >= threshold, nil
}
// Close cancels the shutdown context, stopping async goroutines.
func (e *CompactionEngine) Close() {
if e.shutdownCancel != nil {
e.shutdownCancel()
}
}
// Compact runs leaf compaction (sync) and optionally condensed compaction.
func (e *CompactionEngine) Compact(ctx context.Context, convID int64, input CompactInput) (*CompactResult, error) {
result := &CompactResult{}
// Phase 1: leaf compaction (synchronous, every turn)
summaryID, err := e.compactLeaf(ctx, convID)
if err != nil {
return nil, fmt.Errorf("compact leaf: %w", err)
}
if summaryID != nil {
result.SummariesCreated = append(result.SummariesCreated, *summaryID)
result.LeafSummaries++
logger.InfoCF("seahorse", "compact: leaf", map[string]any{
"conv_id": convID,
"summary_id": *summaryID,
})
}
// Phase 2: condensed compaction if over threshold
tokensBefore, _ := e.store.GetContextTokenCount(ctx, convID)
var budget int
if input.Budget != nil {
budget = *input.Budget
if budget == 0 {
logger.ErrorCF("seahorse", "Compact: budget is 0, this should not happen", map[string]any{
"conv_id": convID,
})
}
} else {
budget = int(float64(tokensBefore) * ContextThreshold)
}
if input.Force || (tokensBefore > budget && budget > 0) {
// Launch async condensed compaction with dedup
if _, loaded := e.condensing.LoadOrStore(convID, struct{}{}); !loaded {
go func() {
defer e.condensing.Delete(convID)
e.runCondensedLoop(e.shutdownCtx, convID)
}()
}
}
tokensAfter, _ := e.store.GetContextTokenCount(ctx, convID)
if tokensAfter < tokensBefore {
result.TokensSaved = tokensBefore - tokensAfter
}
return result, nil
}
// CompactUntilUnder aggressively compacts until context is under budget.
func (e *CompactionEngine) CompactUntilUnder(ctx context.Context, convID int64, budget int) (*CompactResult, error) {
result := &CompactResult{}
prevTokens := 0
logger.InfoCF("seahorse", "compact_until_under: start", map[string]any{"conv_id": convID, "budget": budget})
for iter := 0; iter < MaxCompactIterations; iter++ {
tokens, err := e.store.GetContextTokenCount(ctx, convID)
if err != nil {
return result, fmt.Errorf("get tokens: %w", err)
}
if tokens <= budget {
logger.InfoCF("seahorse", "compact_until_under: done", map[string]any{
"conv_id": convID,
"budget": budget,
"tokens": tokens,
"leaf": result.LeafSummaries,
"condensed": result.CondensedSummaries,
})
return result, nil
}
// Try leaf first
summaryID, err := e.compactLeaf(ctx, convID, true)
if err != nil {
return result, err
}
if summaryID != nil {
result.SummariesCreated = append(result.SummariesCreated, *summaryID)
result.LeafSummaries++
logger.InfoCF("seahorse", "compact_until_under: leaf", map[string]any{
"conv_id": convID,
"summary_id": *summaryID,
})
continue
}
// Try condensed with forced fanout
condensedID, err := e.compactCondensed(ctx, convID)
if err != nil {
return result, err
}
if condensedID != nil {
result.SummariesCreated = append(result.SummariesCreated, *condensedID)
result.CondensedSummaries++
logger.InfoCF("seahorse", "compact_until_under: condensed", map[string]any{
"conv_id": convID,
"summary_id": *condensedID,
})
continue
}
// No progress
newTokens, _ := e.store.GetContextTokenCount(ctx, convID)
if newTokens >= prevTokens {
logger.WarnCF("seahorse", "compact_until_under: no progress", map[string]any{
"conv_id": convID,
"tokens": newTokens,
})
return result, nil
}
prevTokens = newTokens
}
// Safety cap exceeded — see MaxCompactIterations doc for rationale.
logger.WarnCF("seahorse", "compact_until_under: exceeded max iterations", map[string]any{
"conv_id": convID,
"budget": budget,
"iterations": MaxCompactIterations,
"tokens": prevTokens,
})
return result, nil
}
// compactLeaf compresses the oldest contiguous message chunk into a leaf summary.
// When force is true, FreshTailCount protection is bypassed (used by CompactUntilUnder).
func (e *CompactionEngine) compactLeaf(ctx context.Context, convID int64, force ...bool) (*string, error) {
items, err := e.store.GetContextItems(ctx, convID)
if err != nil {
return nil, err
}
// Find oldest contiguous message chunk outside fresh tail
msgCount := 0
msgTokens := 0
for _, item := range items {
if item.ItemType == "message" {
msgCount++
msgTokens += item.TokenCount
}
}
// Trigger if either message count or token threshold is met
if msgCount < LeafMinFanout && msgTokens < LeafChunkTokens {
return nil, nil
}
// Calculate fresh tail boundary (bypass when forced)
useForce := len(force) > 0 && force[0]
tailStartIdx := len(items) - FreshTailCount
if useForce {
tailStartIdx = len(items) // allow compacting everything
}
if tailStartIdx < 0 {
tailStartIdx = 0
}
// Find oldest contiguous message chunk, accumulating up to LeafChunkTokens
var chunk []ContextItem
chunkStart := -1
chunkEnd := -1
accumTokens := 0
for i := 0; i < tailStartIdx; i++ {
if items[i].ItemType == "message" {
if chunkStart == -1 {
chunkStart = i
}
chunkEnd = i
accumTokens += items[i].TokenCount
// Stop accumulating once we reach the token budget
if accumTokens >= LeafChunkTokens {
break
}
} else {
// Non-message breaks the chunk
if chunkStart != -1 && (chunkEnd-chunkStart+1) >= LeafMinFanout {
break
}
chunkStart = -1
chunkEnd = -1
accumTokens = 0
}
}
if chunkStart == -1 || (chunkEnd-chunkStart+1) < LeafMinFanout {
return nil, nil
}
chunk = items[chunkStart : chunkEnd+1]
// Collect messages for the chunk
var messages []Message
for _, item := range chunk {
msg, innerErr := e.store.GetMessageByID(ctx, item.MessageID)
if innerErr != nil {
return nil, innerErr
}
messages = append(messages, *msg)
}
// Get prior summaries for context
priorSummary := ""
priorCount := 0
for i := chunkStart - 1; i >= 0 && priorCount < 2; i-- {
if items[i].ItemType == "summary" {
sum, innerErr2 := e.store.GetSummary(ctx, items[i].SummaryID)
if innerErr2 == nil {
priorSummary = sum.Content + "\n" + priorSummary
priorCount++
}
}
}
// Generate summary
content, err := e.generateLeafSummary(ctx, messages, priorSummary)
if err != nil {
return nil, err
}
// Create summary in store
tokenCount := tokenizer.EstimateMessageTokens(providers.Message{Content: content})
var earliestAt, latestAt *time.Time
if len(messages) > 0 {
earliestAt = &messages[0].CreatedAt
latestAt = &messages[len(messages)-1].CreatedAt
}
summary, err := e.store.CreateSummary(ctx, CreateSummaryInput{
ConversationID: convID,
Kind: SummaryKindLeaf,
Depth: 0,
Content: content,
TokenCount: tokenCount,
EarliestAt: earliestAt,
LatestAt: latestAt,
SourceMessageTokens: sumMessageTokens(messages),
})
if err != nil {
return nil, err
}
// Link to source messages
msgIDs := make([]int64, len(messages))
for i, m := range messages {
msgIDs[i] = m.ID
}
if err := e.store.LinkSummaryToMessages(ctx, summary.SummaryID, msgIDs); err != nil {
return nil, err
}
// Replace context range with summary
if err := e.store.ReplaceContextRangeWithSummary(
ctx, convID, chunk[0].Ordinal, chunk[len(chunk)-1].Ordinal, summary.SummaryID,
); err != nil {
return nil, err
}
return &summary.SummaryID, nil
}
// compactCondensed compresses multiple summaries into one higher-level summary.
func (e *CompactionEngine) compactCondensed(ctx context.Context, convID int64) (*string, error) {
// Try ordinal-aware selection first (respects consecutive ordering)
var candidates []Summary
depths, err := e.store.GetDistinctDepthsInContext(ctx, convID, 0)
if err != nil {
return nil, err
}
for _, depth := range depths {
var chunkAtDepth []Summary
var err2 error
chunkAtDepth, err2 = e.selectOldestChunkAtDepth(ctx, convID, depth)
if err2 != nil {
continue
}
if len(chunkAtDepth) > 0 {
candidates = chunkAtDepth
break
}
}
// Fallback to depth-grouping selection
if len(candidates) == 0 {
candidates, err = e.selectShallowestCondensationCandidate(ctx, convID, false)
if err != nil {
return nil, err
}
}
if len(candidates) == 0 {
return nil, nil
}
// Generate condensed summary
content, err := e.generateCondensedSummary(ctx, candidates)
if err != nil {
return nil, err
}
// Merge metadata
maxDepth := 0
descendantCount := 0
descendantTokenCount := 0
sourceMessageTokens := 0
var earliestAt, latestAt *time.Time
parentIDs := make([]string, len(candidates))
for i, c := range candidates {
parentIDs[i] = c.SummaryID
if c.Depth > maxDepth {
maxDepth = c.Depth
}
descendantCount += c.DescendantCount + 1
descendantTokenCount += c.TokenCount + c.DescendantTokenCount
sourceMessageTokens += c.SourceMessageTokenCount
if c.EarliestAt != nil {
if earliestAt == nil || c.EarliestAt.Before(*earliestAt) {
earliestAt = c.EarliestAt
}
}
if c.LatestAt != nil {
if latestAt == nil || c.LatestAt.After(*latestAt) {
latestAt = c.LatestAt
}
}
}
tokenCount := tokenizer.EstimateMessageTokens(providers.Message{Content: content})
summary, err := e.store.CreateSummary(ctx, CreateSummaryInput{
ConversationID: convID,
Kind: SummaryKindCondensed,
Depth: maxDepth + 1,
Content: content,
TokenCount: tokenCount,
EarliestAt: earliestAt,
LatestAt: latestAt,
DescendantCount: descendantCount,
DescendantTokenCount: descendantTokenCount,
SourceMessageTokens: sourceMessageTokens,
ParentIDs: parentIDs,
})
if err != nil {
return nil, err
}
// Find the ordinal range for the candidate summaries in context
items, err := e.store.GetContextItems(ctx, convID)
if err != nil {
return nil, err
}
candidateSet := make(map[string]bool)
for _, c := range candidates {
candidateSet[c.SummaryID] = true
}
startOrd := -1
endOrd := -1
hasNonCandidate := false
for _, item := range items {
if item.ItemType == "summary" && candidateSet[item.SummaryID] {
if startOrd == -1 {
startOrd, endOrd = item.Ordinal, item.Ordinal
} else {
// Check for non-candidate items between endOrd and current ordinal
for _, it := range items {
if it.Ordinal > endOrd && it.Ordinal <= item.Ordinal {
if it.ItemType != "summary" || !candidateSet[it.SummaryID] {
hasNonCandidate = true
break
}
}
}
if hasNonCandidate {
break
}
if item.Ordinal < startOrd {
startOrd = item.Ordinal
}
if item.Ordinal > endOrd {
endOrd = item.Ordinal
}
}
}
}
if startOrd == -1 || endOrd == -1 {
return nil, nil
}
// Collect candidate summary IDs
candidateIDs := make([]string, 0, len(candidates))
for _, c := range candidates {
candidateIDs = append(candidateIDs, c.SummaryID)
}
if hasNonCandidate {
// Use safe per-item deletion to avoid deleting non-candidate items
if err := e.store.ReplaceContextItemsWithSummary(ctx, convID, candidateIDs, summary.SummaryID); err != nil {
return nil, err
}
} else {
// Candidates are consecutive, use efficient range deletion
if err := e.store.ReplaceContextRangeWithSummary(ctx, convID, startOrd, endOrd, summary.SummaryID); err != nil {
return nil, err
}
}
return &summary.SummaryID, nil
}
// selectShallowestCondensationCandidate finds the shallowest consecutive summary group.
func (e *CompactionEngine) selectShallowestCondensationCandidate(
ctx context.Context, convID int64, forced bool,
) ([]Summary, error) {
items, err := e.store.GetContextItems(ctx, convID)
if err != nil {
return nil, err
}
// Group by depth, find consecutive runs
tailStartIdx := len(items) - FreshTailCount
if tailStartIdx < 0 {
tailStartIdx = 0
}
minFanout := CondensedMinFanout
if forced {
minFanout = CondensedMinFanoutHard
}
// Track depth groups
depthGroups := make(map[int][]ContextItem)
for i := 0; i < tailStartIdx; i++ {
item := items[i]
if item.ItemType != "summary" {
continue
}
sum, err := e.store.GetSummary(ctx, item.SummaryID)
if err != nil {
continue
}
depthGroups[sum.Depth] = append(depthGroups[sum.Depth], item)
}
// Find shallowest depth with enough candidates
// Collect all depths and sort to handle non-consecutive depths
var depths []int
for depth := range depthGroups {
depths = append(depths, depth)
}
sort.Ints(depths)
for _, depth := range depths {
group := depthGroups[depth]
if len(group) >= minFanout {
// Load summaries
var result []Summary
for _, item := range group[:minFanout] {
sum, err := e.store.GetSummary(ctx, item.SummaryID)
if err != nil {
continue
}
result = append(result, *sum)
}
return result, nil
}
}
return nil, nil
}
// selectOldestChunkAtDepth scans context_items from oldest ordinal, collecting consecutive
// summaries at the given depth. Stops at non-summary items, different depth, fresh tail, or
// token overflow. Returns contiguous chunk of summaries.
func (e *CompactionEngine) selectOldestChunkAtDepth(
ctx context.Context, convID int64, targetDepth int,
) ([]Summary, error) {
items, err := e.store.GetContextItems(ctx, convID)
if err != nil {
return nil, err
}
tailStartIdx := len(items) - FreshTailCount
if tailStartIdx < 0 {
tailStartIdx = 0
}
var chunk []Summary
accumTokens := 0
for i := 0; i < tailStartIdx; i++ {
item := items[i]
if item.ItemType != "summary" {
// Non-summary breaks the chunk
break
}
sum, err := e.store.GetSummary(ctx, item.SummaryID)
if err != nil {
break
}
if sum.Depth != targetDepth {
// Different depth breaks the chunk
break
}
if accumTokens+sum.TokenCount > LeafChunkTokens {
// Token overflow stops collection
break
}
chunk = append(chunk, *sum)
accumTokens += sum.TokenCount
}
// Min tokens check: spec line 808
// chunk tokens must be >= max(CondensedTargetTokens, LeafChunkTokens × 0.1) = 2000
minTokens := CondensedTargetTokens // 2000
if accumTokens < minTokens {
return nil, nil
}
return chunk, nil
}
// generateLeafSummary calls the LLM to generate a leaf summary with 3-level escalation.
// Level 1: normal LLM prompt. Level 2: aggressive prompt. Level 3: deterministic truncation.
func (e *CompactionEngine) generateLeafSummary(
ctx context.Context,
messages []Message,
previousSummary string,
) (string, error) {
if e.complete == nil {
return truncateSummary(messages), nil
}
sourceText := formatMessagesForSummary(messages)
inputTokens := sumMessageTokens(messages)
targetTokens := minInt(LeafTargetTokens, int(float64(inputTokens)*0.35))
// Level 1: normal prompt
prompt := buildLeafSummaryPrompt(sourceText, previousSummary, targetTokens)
content, err := e.complete(ctx, prompt, CompleteOptions{
MaxTokens: LeafTargetTokens * 2,
Temperature: 0.3,
})
if err != nil {
return "", err
}
if content == "" {
// Retry with temperature=0
content, err = e.complete(ctx, prompt, CompleteOptions{
MaxTokens: LeafTargetTokens * 2,
Temperature: 0,
})
if err != nil {
return "", err
}
}
// Check if level 1 succeeded
if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) < inputTokens {
return content, nil
}
// Level 2: aggressive prompt
aggressiveTarget := minInt(640, int(float64(inputTokens)*0.20))
aggressivePrompt := buildAggressiveLeafSummaryPrompt(sourceText, previousSummary, aggressiveTarget)
content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{
MaxTokens: aggressiveTarget * 2,
Temperature: 0.3,
})
if err != nil {
return "", err
}
if content == "" {
// Retry with temperature=0
content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{
MaxTokens: aggressiveTarget * 2,
Temperature: 0,
})
if err != nil {
return "", err
}
}
if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) < inputTokens {
return content, nil
}
// Level 3: deterministic truncation
return truncateSummary(messages), nil
}
// generateCondensedSummary calls the LLM to generate a condensed summary with 3-level escalation.
func (e *CompactionEngine) generateCondensedSummary(ctx context.Context, summaries []Summary) (string, error) {
if e.complete == nil {
return truncateCondensedSummaries(summaries), nil
}
sourceText := formatSummariesForCondensation(summaries)
inputTokens := sumSummaryTokens(summaries)
targetTokens := minInt(CondensedTargetTokens, int(float64(inputTokens)*0.35))
// Level 1: normal prompt
prompt := buildCondensedSummaryPrompt(sourceText, targetTokens)
content, err := e.complete(ctx, prompt, CompleteOptions{
MaxTokens: CondensedTargetTokens * 2,
Temperature: 0.3,
})
if err != nil {
return "", err
}
if content == "" {
content, err = e.complete(ctx, prompt, CompleteOptions{
MaxTokens: CondensedTargetTokens * 2,
Temperature: 0,
})
if err != nil {
return "", err
}
}
if content != "" {
return content, nil
}
// Level 2: aggressive prompt
aggressiveTarget := minInt(640, int(float64(inputTokens)*0.20))
aggressivePrompt := buildCondensedSummaryPrompt(sourceText, aggressiveTarget)
content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{
MaxTokens: aggressiveTarget * 2,
Temperature: 0.3,
})
if err != nil {
return "", err
}
if content != "" {
return content, nil
}
// Level 3: deterministic fallback
return truncateCondensedSummaries(summaries), nil
}
// runCondensedLoop runs condensed compaction in a loop until:
// a) context tokens <= threshold (success), OR
// b) No candidate found (nothing to condense), OR
// c) tokensAfter >= tokensBefore (no progress this iteration), OR
// d) tokensAfter >= previousTokens (no improvement over last iteration)
func (e *CompactionEngine) runCondensedLoop(ctx context.Context, convID int64) {
var prevTokens int
for {
select {
case <-ctx.Done():
return
default:
}
tokensBefore, err := e.store.GetContextTokenCount(ctx, convID)
if err != nil {
logger.ErrorCF("seahorse", "condensed: get tokens", map[string]any{"error": err.Error()})
return
}
condensedID, err := e.compactCondensed(ctx, convID)
if err != nil {
logger.ErrorCF("seahorse", "condensed: compact", map[string]any{"error": err.Error()})
return
}
if condensedID == nil {
// No candidate found
logger.DebugCF("seahorse", "condensed: no candidate", map[string]any{"conv_id": convID})
return
}
tokensAfter, _ := e.store.GetContextTokenCount(ctx, convID)
if tokensAfter >= tokensBefore {
// No progress this iteration
logger.DebugCF(
"seahorse",
"condensed: no progress",
map[string]any{"conv_id": convID, "tokens_before": tokensBefore, "tokens_after": tokensAfter},
)
return
}
if tokensAfter >= prevTokens && prevTokens > 0 {
// No improvement over last iteration
logger.DebugCF(
"seahorse",
"condensed: no improvement",
map[string]any{"conv_id": convID, "tokens": tokensAfter},
)
return
}
prevTokens = tokensAfter
}
}
// --- Helper functions ---
func formatMessagesForSummary(messages []Message) string {
var result string
for _, m := range messages {
ts := m.CreatedAt.Format("2006-01-02 15:04 MST")
content := m.Content
if content == "" && len(m.Parts) > 0 {
content = partsToReadableContent(m.Parts)
}
result += fmt.Sprintf("[%s]\n%s\n\n", ts, content)
}
return result
}
func formatSummariesForCondensation(summaries []Summary) string {
var result string
for _, s := range summaries {
earliest := ""
if s.EarliestAt != nil {
earliest = s.EarliestAt.Format("2006-01-02")
}
latest := ""
if s.LatestAt != nil {
latest = s.LatestAt.Format("2006-01-02")
}
result += fmt.Sprintf("[%s - %s]\n%s\n\n", earliest, latest, s.Content)
}
return result
}
func buildLeafSummaryPrompt(sourceText, previousSummary string, targetTokens int) string {
prev := "(none)"
if previousSummary != "" {
prev = previousSummary
}
return fmt.Sprintf(`You summarize a SEGMENT of a conversation for future model turns.
Treat this as incremental memory compaction input, not a full-conversation summary.
Normal summary policy:
- Preserve key decisions, rationale, constraints, and active tasks.
- Keep essential technical details needed to continue work safely.
- Remove obvious repetition and conversational filler.
Output requirements:
- Plain text only.
- No preamble, headings, or markdown formatting.
- Track file operations (created, modified, deleted, renamed) with file paths and current status.
- If no file operations appear, include exactly: "Files: none".
- End with exactly: "Expand for details about: <comma-separated list of what was dropped or compressed>".
- Target length: about %d tokens or less.
<previous_context>
%s
</previous_context>
<conversation_segment>
%s
</conversation_segment>`, targetTokens, prev, sourceText)
}
func buildCondensedSummaryPrompt(sourceText string, targetTokens int) string {
return fmt.Sprintf(`You condense multiple summaries into a single higher-level summary.
Preserve all important decisions, constraints, and outcomes.
Merge overlapping topics. Keep technical details intact.
Output requirements:
- Plain text only.
- No preamble, headings, or markdown formatting.
- End with exactly: "Expand for details about: <comma-separated list>".
- Target length: about %d tokens or less.
<summaries>
%s
</summaries>`, targetTokens, sourceText)
}
func buildAggressiveLeafSummaryPrompt(sourceText, previousSummary string, targetTokens int) string {
prev := "(none)"
if previousSummary != "" {
prev = previousSummary
}
return fmt.Sprintf(`You summarize a SEGMENT of a conversation for future model turns.
Aggressive summary policy:
- Keep only durable facts and current task state.
- Remove examples, repetition, and low-value narrative details.
- Preserve explicit TODOs, blockers, decisions, and constraints.
Output requirements:
- Plain text only.
- No preamble, headings, or markdown formatting.
- Track file operations (created, modified, deleted, renamed) with file paths and current status.
- If no file operations appear, include exactly: "Files: none".
- End with exactly: "Expand for details about: <comma-separated list of what was dropped or compressed>".
- Target length: about %d tokens or less.
<previous_context>
%s
</previous_context>
<conversation_segment>
%s
</conversation_segment>`, targetTokens, prev, sourceText)
}
func truncateSummary(messages []Message) string {
content := ""
for _, m := range messages {
c := m.Content
if c == "" && len(m.Parts) > 0 {
c = partsToReadableContent(m.Parts)
}
content += c + "\n"
}
if len(content) > 2048 {
content = content[:2048]
}
content += fmt.Sprintf("\n[Truncated from %d messages]", len(messages))
return content
}
func truncateCondensedSummaries(summaries []Summary) string {
content := ""
for _, s := range summaries {
content += s.Content + "\n"
}
if len(content) > 2048 {
content = content[:2048]
}
content += fmt.Sprintf("\n[Condensed from %d summaries]", len(summaries))
return content
}
func sumMessageTokens(messages []Message) int {
total := 0
for _, m := range messages {
total += m.TokenCount
}
return total
}
func sumSummaryTokens(summaries []Summary) int {
total := 0
for _, s := range summaries {
total += s.TokenCount
}
return total
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}