mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
15a70ac45c
* 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
899 lines
24 KiB
Go
899 lines
24 KiB
Go
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
|
||
}
|