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 01:05:16 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 12:10:42 +00:00
|
|
|
|
// Level 1 only succeeds if it actually reaches the requested target size.
|
|
|
|
|
|
if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= targetTokens {
|
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 01:05:16 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-04 12:10:42 +00:00
|
|
|
|
if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= aggressiveTarget {
|
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 01:05:16 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|