2026-04-08 02:57:22 +00:00
|
|
|
//go:build !mipsle && !netbsd && !(freebsd && arm)
|
2026-04-07 03:49:35 +00:00
|
|
|
|
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 agent
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/session"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/tokenizer"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// seahorseContextManager adapts seahorse.Engine to agent.ContextManager.
|
|
|
|
|
type seahorseContextManager struct {
|
|
|
|
|
engine *seahorse.Engine
|
|
|
|
|
sessions session.SessionStore // for startup bootstrap
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newSeahorseContextManager creates a seahorse-backed ContextManager.
|
|
|
|
|
func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
|
|
|
|
if al == nil {
|
|
|
|
|
return nil, fmt.Errorf("seahorse: AgentLoop is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve workspace for DB path
|
|
|
|
|
// DB stores session data, so it goes in sessions/ directory
|
|
|
|
|
agent := al.registry.GetDefaultAgent()
|
|
|
|
|
dbPath := agent.Workspace + "/sessions/seahorse.db"
|
|
|
|
|
|
|
|
|
|
// Create CompleteFn from provider
|
|
|
|
|
completeFn := providerToCompleteFn(agent.Provider, agent.Model)
|
|
|
|
|
|
|
|
|
|
// Create engine
|
|
|
|
|
engine, err := seahorse.NewEngine(seahorse.Config{
|
|
|
|
|
DBPath: dbPath,
|
|
|
|
|
}, completeFn)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("seahorse: create engine: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mgr := &seahorseContextManager{
|
|
|
|
|
engine: engine,
|
|
|
|
|
sessions: agent.Sessions,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register seahorse tools with the agent's tool registry
|
|
|
|
|
retrieval := mgr.engine.GetRetrieval()
|
|
|
|
|
al.RegisterTool(seahorse.NewGrepTool(retrieval))
|
|
|
|
|
al.RegisterTool(seahorse.NewExpandTool(retrieval))
|
|
|
|
|
|
|
|
|
|
// Bootstrap all existing sessions at startup
|
|
|
|
|
if agent.Sessions != nil {
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
for _, sessionKey := range agent.Sessions.ListSessions() {
|
|
|
|
|
mgr.bootstrapSession(ctx, sessionKey)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mgr, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// providerToCompleteFn wraps providers.LLMProvider as a seahorse.CompleteFn.
|
|
|
|
|
func providerToCompleteFn(provider providers.LLMProvider, model string) seahorse.CompleteFn {
|
|
|
|
|
return func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) {
|
|
|
|
|
resp, err := provider.Chat(
|
|
|
|
|
ctx,
|
|
|
|
|
[]providers.Message{{Role: "user", Content: prompt}},
|
|
|
|
|
nil, // no tools for summarization
|
|
|
|
|
model,
|
|
|
|
|
map[string]any{
|
|
|
|
|
"max_tokens": opts.MaxTokens,
|
|
|
|
|
"temperature": opts.Temperature,
|
|
|
|
|
"prompt_cache_key": "seahorse",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return resp.Content, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Assemble builds budget-aware context from seahorse SQLite.
|
|
|
|
|
func (m *seahorseContextManager) Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
|
|
|
|
if req == nil {
|
|
|
|
|
return nil, fmt.Errorf("seahorse assemble: nil request")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
budget := req.Budget
|
|
|
|
|
if budget <= 0 {
|
|
|
|
|
budget = 100000
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reserve space for model response (spec lines 1400-1410)
|
|
|
|
|
effectiveBudget := budget - req.MaxTokens
|
|
|
|
|
if effectiveBudget <= 0 {
|
|
|
|
|
// MaxTokens >= budget is a configuration problem
|
|
|
|
|
// Use 50% as minimum to avoid guaranteed overflow
|
|
|
|
|
logger.WarnCF("agent", "MaxTokens >= budget, using 50% fallback",
|
|
|
|
|
map[string]any{"budget": budget, "max_tokens": req.MaxTokens})
|
|
|
|
|
effectiveBudget = budget / 2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result, err := m.engine.Assemble(ctx, req.SessionKey, seahorse.AssembleInput{
|
|
|
|
|
Budget: effectiveBudget,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("seahorse assemble: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
history := seahorseToProviderMessages(result)
|
|
|
|
|
|
|
|
|
|
// Summary is already formatted as XML with system prompt addition by assembler
|
|
|
|
|
return &AssembleResponse{
|
|
|
|
|
History: history,
|
|
|
|
|
Summary: result.Summary,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compact compresses conversation history via seahorse summarization.
|
|
|
|
|
func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactRequest) error {
|
|
|
|
|
if req == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For retry (LLM overflow), use aggressive CompactUntilUnder to guarantee
|
|
|
|
|
// context shrinks below budget (spec lines ~1410).
|
|
|
|
|
if req.Reason == ContextCompressReasonRetry && req.Budget > 0 {
|
|
|
|
|
_, err := m.engine.CompactUntilUnder(ctx, req.SessionKey, req.Budget)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{
|
|
|
|
|
Force: req.Reason == ContextCompressReasonRetry,
|
|
|
|
|
Budget: &req.Budget,
|
|
|
|
|
})
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ingest records a message into seahorse SQLite.
|
|
|
|
|
// All existing sessions are bootstrapped at startup, so this only ingests new messages.
|
|
|
|
|
func (m *seahorseContextManager) Ingest(ctx context.Context, req *IngestRequest) error {
|
|
|
|
|
if req == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg := providerToSeahorseMessage(req.Message)
|
|
|
|
|
_, err := m.engine.Ingest(ctx, req.SessionKey, []seahorse.Message{msg})
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 03:04:45 +00:00
|
|
|
// Clear removes all stored context for a session (seahorse DB + JSONL).
|
|
|
|
|
func (m *seahorseContextManager) Clear(ctx context.Context, sessionKey string) error {
|
|
|
|
|
if err := m.engine.ClearSession(ctx, sessionKey); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if m.sessions != nil {
|
|
|
|
|
m.sessions.SetHistory(sessionKey, []providers.Message{})
|
|
|
|
|
m.sessions.SetSummary(sessionKey, "")
|
|
|
|
|
return m.sessions.Save(sessionKey)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// bootstrapSession reconciles JSONL session history into seahorse SQLite.
|
|
|
|
|
func (m *seahorseContextManager) bootstrapSession(ctx context.Context, sessionKey string) {
|
|
|
|
|
if m.sessions == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
history := m.sessions.GetHistory(sessionKey)
|
|
|
|
|
if len(history) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert provider messages to seahorse messages
|
|
|
|
|
msgs := make([]seahorse.Message, len(history))
|
|
|
|
|
for i, h := range history {
|
|
|
|
|
msgs[i] = providerToSeahorseMessage(h)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := m.engine.Bootstrap(ctx, sessionKey, msgs); err != nil {
|
|
|
|
|
logger.WarnCF("seahorse", "bootstrap", map[string]any{
|
|
|
|
|
"session": sessionKey,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// providerToSeahorseMessage converts a providers.Message to a seahorse.Message.
|
|
|
|
|
func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
|
|
|
|
result := seahorse.Message{
|
|
|
|
|
Role: msg.Role,
|
|
|
|
|
Content: msg.Content,
|
2026-05-20 05:42:21 +00:00
|
|
|
ModelName: msg.ModelName,
|
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
|
|
|
ReasoningContent: msg.ReasoningContent,
|
|
|
|
|
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert ToolCalls → MessageParts
|
|
|
|
|
for _, tc := range msg.ToolCalls {
|
|
|
|
|
part := seahorse.MessagePart{
|
|
|
|
|
Type: "tool_use",
|
|
|
|
|
Name: tc.Function.Name,
|
|
|
|
|
Arguments: tc.Function.Arguments,
|
|
|
|
|
ToolCallID: tc.ID,
|
|
|
|
|
}
|
|
|
|
|
result.Parts = append(result.Parts, part)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert tool result
|
|
|
|
|
if msg.ToolCallID != "" {
|
|
|
|
|
part := seahorse.MessagePart{
|
|
|
|
|
Type: "tool_result",
|
|
|
|
|
ToolCallID: msg.ToolCallID,
|
|
|
|
|
Text: msg.Content,
|
|
|
|
|
}
|
|
|
|
|
result.Parts = append(result.Parts, part)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert media attachments
|
|
|
|
|
for _, mediaURI := range msg.Media {
|
|
|
|
|
part := seahorse.MessagePart{
|
|
|
|
|
Type: "media",
|
|
|
|
|
MediaURI: mediaURI,
|
|
|
|
|
}
|
|
|
|
|
result.Parts = append(result.Parts, part)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
|
|
|
|
|
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
|
|
|
|
|
messages := make([]protocoltypes.Message, 0, len(result.Messages))
|
|
|
|
|
|
|
|
|
|
// Convert assembled messages (which already include summary XML messages)
|
|
|
|
|
for _, msg := range result.Messages {
|
|
|
|
|
pm := protocoltypes.Message{
|
|
|
|
|
Role: msg.Role,
|
|
|
|
|
Content: msg.Content,
|
2026-05-20 05:42:21 +00:00
|
|
|
ModelName: msg.ModelName,
|
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
|
|
|
ReasoningContent: msg.ReasoningContent,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reconstruct ToolCalls from parts
|
|
|
|
|
for _, part := range msg.Parts {
|
|
|
|
|
if part.Type == "tool_use" {
|
|
|
|
|
pm.ToolCalls = append(pm.ToolCalls, protocoltypes.ToolCall{
|
|
|
|
|
ID: part.ToolCallID,
|
|
|
|
|
Type: "function", // Required by OpenAI-compatible APIs (GLM, etc.)
|
|
|
|
|
Function: &protocoltypes.FunctionCall{
|
|
|
|
|
Name: part.Name,
|
|
|
|
|
Arguments: part.Arguments,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if part.Type == "tool_result" {
|
|
|
|
|
pm.ToolCallID = part.ToolCallID
|
|
|
|
|
if pm.Content == "" && part.Text != "" {
|
|
|
|
|
pm.Content = part.Text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if part.Type == "media" && part.MediaURI != "" {
|
|
|
|
|
pm.Media = append(pm.Media, part.MediaURI)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
messages = append(messages, pm)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return messages
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
if err := RegisterContextManager("seahorse", newSeahorseContextManager); err != nil {
|
|
|
|
|
panic(fmt.Sprintf("register seahorse context manager: %v", err))
|
|
|
|
|
}
|
|
|
|
|
}
|