feat: add context compaction to automatically summarize old messages (#compaction) (#276)

When input tokens exceed a configurable threshold after a conversation round,
the system asynchronously compacts older messages into a summary. Cascading
compactions reference prior summaries via <prior_context> tags to maintain
conversational continuity without duplicating content.

- Add bot_history_message_compacts table and compact_id on messages
- Add compaction_enabled, compaction_threshold, compaction_model_id to bots
- Implement compaction service (internal/compaction) with LLM summarization
- Integrate into conversation flow: replace compacted messages with summaries
  wrapped in <summary> tags during context loading
- Add REST API endpoints (GET/DELETE /bots/:bot_id/compaction/logs)
- Add frontend Compaction tab with settings and log viewer
- Wire compaction service into both dev (cmd/agent) and prod (cmd/memoh) entry points
- Update test mocks to include new GetBotByID columns
This commit is contained in:
Acbox Liu
2026-03-22 14:26:00 +08:00
committed by GitHub
parent 91e5e44509
commit de62f94315
40 changed files with 2375 additions and 197 deletions
+6 -3
View File
@@ -74,9 +74,12 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
*dest[14].(*bool) = false
*dest[15].(*int32) = 30
*dest[16].(*string) = ""
*dest[17].(*[]byte) = []byte(`{}`)
*dest[18].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[19].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[17].(*bool) = false // CompactionEnabled
*dest[18].(*int32) = 100000 // CompactionThreshold
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[20].(*[]byte) = []byte(`{}`)
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
+10 -5
View File
@@ -44,11 +44,13 @@ func (d *fakeDBTX) QueryRow(ctx context.Context, sql string, args ...any) pgx.Ro
// Column order: id, owner_user_id, display_name, avatar_url, is_active, status,
// max_context_load_time, max_context_tokens, language,
// reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id,
// heartbeat_enabled, heartbeat_interval, heartbeat_prompt, metadata, created_at, updated_at.
// heartbeat_enabled, heartbeat_interval, heartbeat_prompt,
// compaction_enabled, compaction_threshold, compaction_model_id,
// metadata, created_at, updated_at.
func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
if len(dest) < 20 {
if len(dest) < 23 {
return pgx.ErrNoRows
}
*dest[0].(*pgtype.UUID) = botID
@@ -68,9 +70,12 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
*dest[14].(*bool) = false // HeartbeatEnabled
*dest[15].(*int32) = 30 // HeartbeatInterval
*dest[16].(*string) = "" // HeartbeatPrompt
*dest[17].(*[]byte) = []byte(`{}`)
*dest[18].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[19].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[17].(*bool) = false // CompactionEnabled
*dest[18].(*int32) = 100000 // CompactionThreshold
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[20].(*[]byte) = []byte(`{}`)
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
+39
View File
@@ -0,0 +1,39 @@
package compaction
import (
"fmt"
"strings"
)
const systemPrompt = `You are a conversation summarizer. Given a conversation history, produce a concise summary that preserves:
- Key facts, decisions, and agreements
- User preferences and requests
- Important context needed for continuing the conversation
- Names, dates, numbers, and specific details
- Tool usage outcomes and their results
If <prior_context> is provided, it contains summaries of earlier conversation segments. Use them ONLY to understand the conversation flow and maintain continuity. Do NOT include, repeat, or rephrase any content from <prior_context> in your output.
Output ONLY the summary of the new conversation segment. No preamble, no headers.`
type messageEntry struct {
Role string
Content string
}
func buildUserPrompt(priorSummaries []string, messages []messageEntry) string {
var sb strings.Builder
if len(priorSummaries) > 0 {
sb.WriteString("<prior_context>\n")
sb.WriteString("The following are summaries of earlier parts of this conversation. They are provided ONLY as reference context to help you understand the conversation flow. Do NOT include or repeat any of this content in your output summary.\n\n")
sb.WriteString(strings.Join(priorSummaries, "\n---\n"))
sb.WriteString("\n</prior_context>\n\n")
sb.WriteString("Now summarize the following conversation segment:\n")
} else {
sb.WriteString("Summarize the following conversation:\n")
}
for _, m := range messages {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
return sb.String()
}
+256
View File
@@ -0,0 +1,256 @@
package compaction
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
sdk "github.com/memohai/twilight-ai/sdk"
"github.com/memohai/memoh/internal/agent"
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/db/sqlc"
)
// Service manages context compaction for bot conversations.
type Service struct {
queries *sqlc.Queries
logger *slog.Logger
}
// NewService creates a new compaction Service.
func NewService(log *slog.Logger, queries *sqlc.Queries) *Service {
return &Service{
queries: queries,
logger: log,
}
}
// ShouldCompact returns true if inputTokens exceeds the threshold.
func ShouldCompact(inputTokens, threshold int) bool {
return threshold > 0 && inputTokens >= threshold
}
// TriggerCompaction runs compaction in the background.
func (s *Service) TriggerCompaction(ctx context.Context, cfg TriggerConfig) {
go func() {
bgCtx := context.WithoutCancel(ctx)
if err := s.runCompaction(bgCtx, cfg); err != nil {
s.logger.Error("compaction failed", slog.String("bot_id", cfg.BotID), slog.String("session_id", cfg.SessionID), slog.String("error", err.Error()))
}
}()
}
func (s *Service) runCompaction(ctx context.Context, cfg TriggerConfig) error {
botUUID, err := db.ParseUUID(cfg.BotID)
if err != nil {
return err
}
sessionUUID, err := db.ParseUUID(cfg.SessionID)
if err != nil {
return err
}
logRow, err := s.queries.CreateCompactionLog(ctx, sqlc.CreateCompactionLogParams{
BotID: botUUID,
SessionID: sessionUUID,
})
if err != nil {
return err
}
compactErr := s.doCompaction(ctx, logRow.ID, sessionUUID, cfg)
if compactErr != nil {
s.completeLog(ctx, logRow.ID, "error", "", compactErr.Error(), nil, pgtype.UUID{})
}
return compactErr
}
func (s *Service) doCompaction(ctx context.Context, logID pgtype.UUID, sessionUUID pgtype.UUID, cfg TriggerConfig) error {
messages, err := s.queries.ListUncompactedMessagesBySession(ctx, sessionUUID)
if err != nil {
return err
}
if len(messages) == 0 {
s.completeLog(ctx, logID, "ok", "", "", nil, pgtype.UUID{})
return nil
}
priorLogs, err := s.queries.ListCompactionLogsBySession(ctx, sessionUUID)
if err != nil {
return err
}
var priorSummaries []string
for _, l := range priorLogs {
if l.Summary != "" {
priorSummaries = append(priorSummaries, l.Summary)
}
}
entries := make([]messageEntry, 0, len(messages))
messageIDs := make([]pgtype.UUID, 0, len(messages))
for _, m := range messages {
entries = append(entries, messageEntry{
Role: m.Role,
Content: extractTextContent(m.Content),
})
messageIDs = append(messageIDs, m.ID)
}
userPrompt := buildUserPrompt(priorSummaries, entries)
model := agent.CreateModel(agent.ModelConfig{
ClientType: cfg.ClientType,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
ModelID: cfg.ModelID,
})
result, err := sdk.GenerateTextResult(ctx,
sdk.WithModel(model),
sdk.WithSystem(systemPrompt),
sdk.WithMessages([]sdk.Message{sdk.UserMessage(userPrompt)}),
)
if err != nil {
return err
}
usageJSON, _ := json.Marshal(result.Usage)
modelUUID := db.ParseUUIDOrEmpty(cfg.ModelID)
if err := s.queries.MarkMessagesCompacted(ctx, sqlc.MarkMessagesCompactedParams{
CompactID: logID,
MessageIds: messageIDs,
}); err != nil {
return err
}
s.completeLog(ctx, logID, "ok", result.Text, "", usageJSON, modelUUID)
return nil
}
func (s *Service) completeLog(ctx context.Context, logID pgtype.UUID, status, summary, errMsg string, usage []byte, modelID pgtype.UUID) {
if _, err := s.queries.CompleteCompactionLog(ctx, sqlc.CompleteCompactionLogParams{
ID: logID,
Status: status,
Summary: summary,
MessageCount: 0,
ErrorMessage: errMsg,
Usage: usage,
ModelID: modelID,
}); err != nil {
s.logger.Error("failed to complete compaction log", slog.String("error", err.Error()))
}
}
// ListLogs returns paginated compaction logs for a bot.
func (s *Service) ListLogs(ctx context.Context, botID string, before *time.Time, limit int) ([]Log, error) {
botUUID, err := db.ParseUUID(botID)
if err != nil {
return nil, err
}
var beforeTS pgtype.Timestamptz
if before != nil {
beforeTS = pgtype.Timestamptz{Time: *before, Valid: true}
}
clampedLimit := limit
if clampedLimit > 1000 {
clampedLimit = 1000
}
rows, err := s.queries.ListCompactionLogsByBot(ctx, sqlc.ListCompactionLogsByBotParams{
BotID: botUUID,
Column2: beforeTS,
Limit: int32(clampedLimit), //nolint:gosec // clamped above
})
if err != nil {
return nil, err
}
logs := make([]Log, len(rows))
for i, r := range rows {
logs[i] = toLog(r)
}
return logs, nil
}
// DeleteLogs deletes all compaction logs for a bot.
func (s *Service) DeleteLogs(ctx context.Context, botID string) error {
botUUID, err := db.ParseUUID(botID)
if err != nil {
return err
}
return s.queries.DeleteCompactionLogsByBot(ctx, botUUID)
}
func toLog(r sqlc.BotHistoryMessageCompact) Log {
l := Log{
ID: formatUUID(r.ID),
BotID: formatUUID(r.BotID),
SessionID: formatUUID(r.SessionID),
Status: r.Status,
Summary: r.Summary,
MessageCount: int(r.MessageCount),
ErrorMessage: r.ErrorMessage,
ModelID: formatUUID(r.ModelID),
StartedAt: r.StartedAt.Time,
}
if r.CompletedAt.Valid {
t := r.CompletedAt.Time
l.CompletedAt = &t
}
if len(r.Usage) > 0 {
var u any
if json.Unmarshal(r.Usage, &u) == nil {
l.Usage = u
}
}
return l
}
func formatUUID(id pgtype.UUID) string {
if !id.Valid {
return ""
}
return uuid.UUID(id.Bytes).String()
}
// extractTextContent extracts plain text from a message content JSONB field.
// The content may be a JSON string, an array of content parts, or raw bytes.
func extractTextContent(content []byte) string {
if len(content) == 0 {
return ""
}
var s string
if json.Unmarshal(content, &s) == nil {
return s
}
var parts []map[string]any
if json.Unmarshal(content, &parts) == nil {
var texts []string
for _, p := range parts {
if t, ok := p["type"].(string); ok && t == "text" {
if text, ok := p["text"].(string); ok {
texts = append(texts, text)
}
}
}
if len(texts) > 0 {
return joinTexts(texts)
}
}
return string(content)
}
func joinTexts(parts []string) string {
return strings.Join(parts, " ")
}
+33
View File
@@ -0,0 +1,33 @@
package compaction
import "time"
// Log represents a compaction log entry.
type Log struct {
ID string `json:"id"`
BotID string `json:"bot_id"`
SessionID string `json:"session_id,omitempty"`
Status string `json:"status"`
Summary string `json:"summary"`
MessageCount int `json:"message_count"`
ErrorMessage string `json:"error_message"`
Usage any `json:"usage,omitempty"`
ModelID string `json:"model_id,omitempty"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// ListLogsResponse is the API response for listing compaction logs.
type ListLogsResponse struct {
Items []Log `json:"items"`
}
// TriggerConfig holds the parameters needed to trigger a compaction.
type TriggerConfig struct {
BotID string
SessionID string
ModelID string
ClientType string
APIKey string //nolint:gosec // runtime credential, not a hardcoded secret
BaseURL string
}
+26 -13
View File
@@ -11,6 +11,7 @@ import (
sdk "github.com/memohai/twilight-ai/sdk"
agentpkg "github.com/memohai/memoh/internal/agent"
"github.com/memohai/memoh/internal/compaction"
"github.com/memohai/memoh/internal/conversation"
"github.com/memohai/memoh/internal/db/sqlc"
memprovider "github.com/memohai/memoh/internal/memory/adapters"
@@ -49,19 +50,20 @@ type gatewayAssetLoader interface {
// Resolver orchestrates chat with the internal agent.
type Resolver struct {
agent *agentpkg.Agent
modelsService *models.Service
queries *sqlc.Queries
memoryRegistry *memprovider.Registry
conversationSvc ConversationSettingsReader
messageService messagepkg.Service
settingsService *settings.Service
sessionService SessionService
eventPublisher messageevent.Publisher
skillLoader SkillLoader
assetLoader gatewayAssetLoader
timeout time.Duration
logger *slog.Logger
agent *agentpkg.Agent
modelsService *models.Service
queries *sqlc.Queries
memoryRegistry *memprovider.Registry
conversationSvc ConversationSettingsReader
messageService messagepkg.Service
settingsService *settings.Service
sessionService SessionService
compactionService *compaction.Service
eventPublisher messageevent.Publisher
skillLoader SkillLoader
assetLoader gatewayAssetLoader
timeout time.Duration
logger *slog.Logger
}
// NewResolver creates a Resolver that uses the internal agent directly.
@@ -106,6 +108,11 @@ func (r *Resolver) SetGatewayAssetLoader(loader gatewayAssetLoader) {
r.assetLoader = loader
}
// SetCompactionService configures the compaction service for context compaction.
func (r *Resolver) SetCompactionService(s *compaction.Service) {
r.compactionService = s
}
type usageInfo struct {
InputTokens *int `json:"inputTokens"`
OutputTokens *int `json:"outputTokens"`
@@ -199,6 +206,7 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r
}
loaded = pruneHistoryForGateway(loaded)
loaded = dedupePersistedCurrentUserMessage(loaded, req)
loaded = r.replaceCompactedMessages(ctx, loaded)
messages = trimMessagesByTokens(r.logger, loaded, historyBudget)
r.logger.Debug("context trim result",
slog.Int("loaded_messages", len(loaded)),
@@ -318,6 +326,11 @@ func (r *Resolver) Chat(ctx context.Context, req conversation.ChatRequest) (conv
if err := r.storeRound(ctx, req, roundMessages, rc.model.ID); err != nil {
return conversation.ChatResponse{}, err
}
if result.Usage != nil {
go r.maybeCompact(context.WithoutCancel(ctx), req, rc, result.Usage.InputTokens)
}
return conversation.ChatResponse{
Messages: outputMessages,
Model: rc.model.ModelID,
@@ -0,0 +1,55 @@
package flow
import (
"context"
"log/slog"
"github.com/memohai/memoh/internal/compaction"
"github.com/memohai/memoh/internal/conversation"
"github.com/memohai/memoh/internal/models"
)
func (r *Resolver) maybeCompact(ctx context.Context, req conversation.ChatRequest, rc resolvedContext, inputTokens int) {
if r.compactionService == nil || r.settingsService == nil {
return
}
settings, err := r.settingsService.GetBot(ctx, req.BotID)
if err != nil {
r.logger.Warn("compaction: failed to load settings", slog.Any("error", err))
return
}
if !settings.CompactionEnabled || settings.CompactionThreshold <= 0 {
return
}
if !compaction.ShouldCompact(inputTokens, settings.CompactionThreshold) {
return
}
modelID := settings.CompactionModelID
if modelID == "" {
modelID = rc.model.ID
}
cfg := compaction.TriggerConfig{
BotID: req.BotID,
SessionID: req.SessionID,
}
model, err := r.modelsService.GetByID(ctx, modelID)
if err != nil {
r.logger.Warn("compaction: failed to resolve model", slog.Any("error", err))
return
}
cfg.ModelID = model.ModelID
cfg.ClientType = string(model.ClientType)
provider, err := models.FetchProviderByID(ctx, r.queries, model.LlmProviderID)
if err != nil {
r.logger.Warn("compaction: failed to fetch provider", slog.Any("error", err))
return
}
cfg.APIKey = provider.ApiKey
cfg.BaseURL = provider.BaseUrl
r.compactionService.TriggerCompaction(ctx, cfg)
}
@@ -8,6 +8,7 @@ import (
"time"
"github.com/memohai/memoh/internal/conversation"
"github.com/memohai/memoh/internal/db"
messagepkg "github.com/memohai/memoh/internal/message"
)
@@ -19,6 +20,7 @@ type messageWithUsage struct {
ExternalMessageID string
Platform string
SenderChannelID string
CompactID string
}
func (r *Resolver) loadMessages(ctx context.Context, chatID string, sessionID string, maxContextMinutes int) ([]messageWithUsage, error) {
@@ -63,6 +65,7 @@ func (r *Resolver) loadMessages(ctx context.Context, chatID string, sessionID st
ExternalMessageID: strings.TrimSpace(m.ExternalMessageID),
Platform: strings.TrimSpace(m.Platform),
SenderChannelID: strings.TrimSpace(m.SenderChannelIdentityID),
CompactID: strings.TrimSpace(m.CompactID),
})
}
return result, nil
@@ -165,3 +168,62 @@ func trimMessagesByTokens(log *slog.Logger, messages []messageWithUsage, maxToke
}
return result
}
func (r *Resolver) replaceCompactedMessages(ctx context.Context, messages []messageWithUsage) []messageWithUsage {
if r.queries == nil {
return messages
}
compactGroups := make(map[string][]int) // compact_id -> indices
for i, m := range messages {
if m.CompactID != "" {
compactGroups[m.CompactID] = append(compactGroups[m.CompactID], i)
}
}
if len(compactGroups) == 0 {
return messages
}
summaries := make(map[string]string)
for compactID := range compactGroups {
cUUID, err := db.ParseUUID(compactID)
if err != nil {
continue
}
log, err := r.queries.GetCompactionLogByID(ctx, cUUID)
if err != nil {
r.logger.Warn("replaceCompactedMessages: failed to load compact log", slog.String("compact_id", compactID), slog.Any("error", err))
continue
}
if log.Status == "ok" && log.Summary != "" {
summaries[compactID] = log.Summary
}
}
var result []messageWithUsage
replaced := make(map[string]bool)
for _, m := range messages {
if m.CompactID == "" {
result = append(result, m)
continue
}
if replaced[m.CompactID] {
continue
}
replaced[m.CompactID] = true
summary, ok := summaries[m.CompactID]
if !ok || summary == "" {
for _, idx := range compactGroups[m.CompactID] {
result = append(result, messages[idx])
}
continue
}
result = append(result, messageWithUsage{
Message: conversation.ModelMessage{
Role: "user",
Content: json.RawMessage(`"<summary>\n` + summary + `\n</summary>"`),
},
})
}
return result
}
+26 -4
View File
@@ -63,7 +63,7 @@ func (r *Resolver) StreamChat(ctx context.Context, req conversation.ChatRequest)
continue
}
if !stored && event.IsTerminal() && len(event.Messages) > 0 {
if _, storeErr := r.tryStoreStream(ctx, streamReq, data, rc.model.ID); storeErr != nil {
if _, storeErr := r.tryStoreStream(ctx, streamReq, data, rc.model.ID, rc); storeErr != nil {
r.logger.Error("stream persist failed", slog.Any("error", storeErr))
} else {
stored = true
@@ -124,7 +124,7 @@ func (r *Resolver) StreamChatWS(
}
if !stored && event.IsTerminal() && len(event.Messages) > 0 {
if _, storeErr := r.tryStoreStream(ctx, req, data, modelID); storeErr != nil {
if _, storeErr := r.tryStoreStream(ctx, req, data, modelID, rc); storeErr != nil {
r.logger.Error("ws persist failed", slog.Any("error", storeErr))
} else {
stored = true
@@ -142,10 +142,11 @@ func (r *Resolver) StreamChatWS(
}
// tryStoreStream attempts to extract final messages from a stream event and persist them.
func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequest, data []byte, modelID string) (bool, error) {
func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequest, data []byte, modelID string, rc resolvedContext) (bool, error) {
var envelope struct {
Type string `json:"type"`
Messages json.RawMessage `json:"messages"`
Usage json.RawMessage `json:"usage,omitempty"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
return false, nil
@@ -161,5 +162,26 @@ func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequ
outputMessages := sdkMessagesToModelMessages(sdkMsgs)
roundMessages := prependUserMessage(req.Query, outputMessages)
return true, r.storeRound(ctx, req, roundMessages, modelID)
if err := r.storeRound(ctx, req, roundMessages, modelID); err != nil {
return false, err
}
if inputTokens := extractInputTokensFromUsage(envelope.Usage); inputTokens > 0 {
go r.maybeCompact(context.WithoutCancel(ctx), req, rc, inputTokens)
}
return true, nil
}
func extractInputTokensFromUsage(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
var u struct {
InputTokens int `json:"inputTokens"`
}
if json.Unmarshal(raw, &u) != nil {
return 0
}
return u.InputTokens
}
+27 -21
View File
@@ -94,32 +94,35 @@ func (q *Queries) DeleteBotByID(ctx context.Context, id pgtype.UUID) error {
}
const getBotByID = `-- name: GetBotByID :one
SELECT id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, metadata, created_at, updated_at
SELECT id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, compaction_enabled, compaction_threshold, compaction_model_id, metadata, created_at, updated_at
FROM bots
WHERE id = $1
`
type GetBotByIDRow struct {
ID pgtype.UUID `json:"id"`
OwnerUserID pgtype.UUID `json:"owner_user_id"`
DisplayName pgtype.Text `json:"display_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ID pgtype.UUID `json:"id"`
OwnerUserID pgtype.UUID `json:"owner_user_id"`
DisplayName pgtype.Text `json:"display_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetBotByID(ctx context.Context, id pgtype.UUID) (GetBotByIDRow, error) {
@@ -143,6 +146,9 @@ func (q *Queries) GetBotByID(ctx context.Context, id pgtype.UUID) (GetBotByIDRow
&i.HeartbeatEnabled,
&i.HeartbeatInterval,
&i.HeartbeatPrompt,
&i.CompactionEnabled,
&i.CompactionThreshold,
&i.CompactionModelID,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
+225
View File
@@ -0,0 +1,225 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: compaction_logs.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const completeCompactionLog = `-- name: CompleteCompactionLog :one
UPDATE bot_history_message_compacts
SET status = $2,
summary = $3,
message_count = $4,
error_message = $5,
usage = $6,
model_id = $7,
completed_at = now()
WHERE id = $1
RETURNING id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
`
type CompleteCompactionLogParams struct {
ID pgtype.UUID `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
MessageCount int32 `json:"message_count"`
ErrorMessage string `json:"error_message"`
Usage []byte `json:"usage"`
ModelID pgtype.UUID `json:"model_id"`
}
func (q *Queries) CompleteCompactionLog(ctx context.Context, arg CompleteCompactionLogParams) (BotHistoryMessageCompact, error) {
row := q.db.QueryRow(ctx, completeCompactionLog,
arg.ID,
arg.Status,
arg.Summary,
arg.MessageCount,
arg.ErrorMessage,
arg.Usage,
arg.ModelID,
)
var i BotHistoryMessageCompact
err := row.Scan(
&i.ID,
&i.BotID,
&i.SessionID,
&i.Status,
&i.Summary,
&i.MessageCount,
&i.ErrorMessage,
&i.Usage,
&i.ModelID,
&i.StartedAt,
&i.CompletedAt,
)
return i, err
}
const createCompactionLog = `-- name: CreateCompactionLog :one
INSERT INTO bot_history_message_compacts (bot_id, session_id, started_at)
VALUES ($1, $2::uuid, now())
RETURNING id, bot_id, session_id, status, summary, message_count, error_message, usage, started_at, completed_at
`
type CreateCompactionLogParams struct {
BotID pgtype.UUID `json:"bot_id"`
SessionID pgtype.UUID `json:"session_id"`
}
type CreateCompactionLogRow struct {
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
SessionID pgtype.UUID `json:"session_id"`
Status string `json:"status"`
Summary string `json:"summary"`
MessageCount int32 `json:"message_count"`
ErrorMessage string `json:"error_message"`
Usage []byte `json:"usage"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
func (q *Queries) CreateCompactionLog(ctx context.Context, arg CreateCompactionLogParams) (CreateCompactionLogRow, error) {
row := q.db.QueryRow(ctx, createCompactionLog, arg.BotID, arg.SessionID)
var i CreateCompactionLogRow
err := row.Scan(
&i.ID,
&i.BotID,
&i.SessionID,
&i.Status,
&i.Summary,
&i.MessageCount,
&i.ErrorMessage,
&i.Usage,
&i.StartedAt,
&i.CompletedAt,
)
return i, err
}
const deleteCompactionLogsByBot = `-- name: DeleteCompactionLogsByBot :exec
DELETE FROM bot_history_message_compacts WHERE bot_id = $1
`
func (q *Queries) DeleteCompactionLogsByBot(ctx context.Context, botID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteCompactionLogsByBot, botID)
return err
}
const getCompactionLogByID = `-- name: GetCompactionLogByID :one
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
FROM bot_history_message_compacts
WHERE id = $1
`
func (q *Queries) GetCompactionLogByID(ctx context.Context, id pgtype.UUID) (BotHistoryMessageCompact, error) {
row := q.db.QueryRow(ctx, getCompactionLogByID, id)
var i BotHistoryMessageCompact
err := row.Scan(
&i.ID,
&i.BotID,
&i.SessionID,
&i.Status,
&i.Summary,
&i.MessageCount,
&i.ErrorMessage,
&i.Usage,
&i.ModelID,
&i.StartedAt,
&i.CompletedAt,
)
return i, err
}
const listCompactionLogsByBot = `-- name: ListCompactionLogsByBot :many
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
FROM bot_history_message_compacts
WHERE bot_id = $1
AND ($2::timestamptz IS NULL OR started_at < $2::timestamptz)
ORDER BY started_at DESC
LIMIT $3
`
type ListCompactionLogsByBotParams struct {
BotID pgtype.UUID `json:"bot_id"`
Column2 pgtype.Timestamptz `json:"column_2"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListCompactionLogsByBot(ctx context.Context, arg ListCompactionLogsByBotParams) ([]BotHistoryMessageCompact, error) {
rows, err := q.db.Query(ctx, listCompactionLogsByBot, arg.BotID, arg.Column2, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []BotHistoryMessageCompact
for rows.Next() {
var i BotHistoryMessageCompact
if err := rows.Scan(
&i.ID,
&i.BotID,
&i.SessionID,
&i.Status,
&i.Summary,
&i.MessageCount,
&i.ErrorMessage,
&i.Usage,
&i.ModelID,
&i.StartedAt,
&i.CompletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCompactionLogsBySession = `-- name: ListCompactionLogsBySession :many
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
FROM bot_history_message_compacts
WHERE session_id = $1
AND status = 'ok'
ORDER BY started_at ASC
`
func (q *Queries) ListCompactionLogsBySession(ctx context.Context, sessionID pgtype.UUID) ([]BotHistoryMessageCompact, error) {
rows, err := q.db.Query(ctx, listCompactionLogsBySession, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []BotHistoryMessageCompact
for rows.Next() {
var i BotHistoryMessageCompact
if err := rows.Scan(
&i.ID,
&i.BotID,
&i.SessionID,
&i.Status,
&i.Summary,
&i.MessageCount,
&i.ErrorMessage,
&i.Usage,
&i.ModelID,
&i.StartedAt,
&i.CompletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+1 -1
View File
@@ -511,7 +511,7 @@ WITH updated AS (
SET display_name = $1,
updated_at = now()
WHERE bots.id = $2
RETURNING id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, title_model_id, tts_model_id, browser_context_id, metadata, created_at, updated_at
RETURNING id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, title_model_id, tts_model_id, browser_context_id, compaction_enabled, compaction_threshold, compaction_model_id, metadata, created_at, updated_at
)
SELECT
updated.id AS id,
+64
View File
@@ -147,6 +147,7 @@ SELECT
m.content,
m.metadata,
m.usage,
m.compact_id,
m.created_at,
ci.display_name AS sender_display_name,
ci.avatar_url AS sender_avatar_url,
@@ -177,6 +178,7 @@ type ListActiveMessagesSinceRow struct {
Content []byte `json:"content"`
Metadata []byte `json:"metadata"`
Usage []byte `json:"usage"`
CompactID pgtype.UUID `json:"compact_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
SenderDisplayName pgtype.Text `json:"sender_display_name"`
SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"`
@@ -204,6 +206,7 @@ func (q *Queries) ListActiveMessagesSince(ctx context.Context, arg ListActiveMes
&i.Content,
&i.Metadata,
&i.Usage,
&i.CompactID,
&i.CreatedAt,
&i.SenderDisplayName,
&i.SenderAvatarUrl,
@@ -232,6 +235,7 @@ SELECT
m.content,
m.metadata,
m.usage,
m.compact_id,
m.created_at,
ci.display_name AS sender_display_name,
ci.avatar_url AS sender_avatar_url,
@@ -262,6 +266,7 @@ type ListActiveMessagesSinceBySessionRow struct {
Content []byte `json:"content"`
Metadata []byte `json:"metadata"`
Usage []byte `json:"usage"`
CompactID pgtype.UUID `json:"compact_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
SenderDisplayName pgtype.Text `json:"sender_display_name"`
SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"`
@@ -289,6 +294,7 @@ func (q *Queries) ListActiveMessagesSinceBySession(ctx context.Context, arg List
&i.Content,
&i.Metadata,
&i.Usage,
&i.CompactID,
&i.CreatedAt,
&i.SenderDisplayName,
&i.SenderAvatarUrl,
@@ -1050,6 +1056,64 @@ func (q *Queries) ListObservedConversationsByChannelIdentity(ctx context.Context
return items, nil
}
const listUncompactedMessagesBySession = `-- name: ListUncompactedMessagesBySession :many
SELECT id, role, content, usage, created_at
FROM bot_history_messages
WHERE session_id = $1
AND compact_id IS NULL
ORDER BY created_at ASC
`
type ListUncompactedMessagesBySessionRow struct {
ID pgtype.UUID `json:"id"`
Role string `json:"role"`
Content []byte `json:"content"`
Usage []byte `json:"usage"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListUncompactedMessagesBySession(ctx context.Context, sessionID pgtype.UUID) ([]ListUncompactedMessagesBySessionRow, error) {
rows, err := q.db.Query(ctx, listUncompactedMessagesBySession, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUncompactedMessagesBySessionRow
for rows.Next() {
var i ListUncompactedMessagesBySessionRow
if err := rows.Scan(
&i.ID,
&i.Role,
&i.Content,
&i.Usage,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markMessagesCompacted = `-- name: MarkMessagesCompacted :exec
UPDATE bot_history_messages
SET compact_id = $1
WHERE id = ANY($2::uuid[])
`
type MarkMessagesCompactedParams struct {
CompactID pgtype.UUID `json:"compact_id"`
MessageIds []pgtype.UUID `json:"message_ids"`
}
func (q *Queries) MarkMessagesCompacted(ctx context.Context, arg MarkMessagesCompactedParams) error {
_, err := q.db.Exec(ctx, markMessagesCompacted, arg.CompactID, arg.MessageIds)
return err
}
const searchMessages = `-- name: SearchMessages :many
SELECT
m.id,
+42 -24
View File
@@ -9,30 +9,33 @@ import (
)
type Bot struct {
ID pgtype.UUID `json:"id"`
OwnerUserID pgtype.UUID `json:"owner_user_id"`
DisplayName pgtype.Text `json:"display_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ID pgtype.UUID `json:"id"`
OwnerUserID pgtype.UUID `json:"owner_user_id"`
DisplayName pgtype.Text `json:"display_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type BotAclRule struct {
@@ -121,6 +124,7 @@ type BotHistoryMessage struct {
Metadata []byte `json:"metadata"`
Usage []byte `json:"usage"`
ModelID pgtype.UUID `json:"model_id"`
CompactID pgtype.UUID `json:"compact_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
@@ -135,6 +139,20 @@ type BotHistoryMessageAsset struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type BotHistoryMessageCompact struct {
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
SessionID pgtype.UUID `json:"session_id"`
Status string `json:"status"`
Summary string `json:"summary"`
MessageCount int32 `json:"message_count"`
ErrorMessage string `json:"error_message"`
Usage []byte `json:"usage"`
ModelID pgtype.UUID `json:"model_id"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type BotSession struct {
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
+89 -57
View File
@@ -21,8 +21,11 @@ SET max_context_load_time = 1440,
heartbeat_enabled = false,
heartbeat_interval = 30,
heartbeat_prompt = '',
compaction_enabled = false,
compaction_threshold = 100000,
chat_model_id = NULL,
heartbeat_model_id = NULL,
compaction_model_id = NULL,
title_model_id = NULL,
search_provider_id = NULL,
memory_provider_id = NULL,
@@ -48,8 +51,11 @@ SELECT
bots.heartbeat_enabled,
bots.heartbeat_interval,
bots.heartbeat_prompt,
bots.compaction_enabled,
bots.compaction_threshold,
chat_models.id AS chat_model_id,
heartbeat_models.id AS heartbeat_model_id,
compaction_models.id AS compaction_model_id,
title_models.id AS title_model_id,
search_providers.id AS search_provider_id,
memory_providers.id AS memory_provider_id,
@@ -58,6 +64,7 @@ SELECT
FROM bots
LEFT JOIN models AS chat_models ON chat_models.id = bots.chat_model_id
LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = bots.heartbeat_model_id
LEFT JOIN models AS compaction_models ON compaction_models.id = bots.compaction_model_id
LEFT JOIN models AS title_models ON title_models.id = bots.title_model_id
LEFT JOIN search_providers ON search_providers.id = bots.search_provider_id
LEFT JOIN memory_providers ON memory_providers.id = bots.memory_provider_id
@@ -67,22 +74,25 @@ WHERE bots.id = $1
`
type GetSettingsByBotIDRow struct {
BotID pgtype.UUID `json:"bot_id"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
BotID pgtype.UUID `json:"bot_id"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
}
func (q *Queries) GetSettingsByBotID(ctx context.Context, id pgtype.UUID) (GetSettingsByBotIDRow, error) {
@@ -98,8 +108,11 @@ func (q *Queries) GetSettingsByBotID(ctx context.Context, id pgtype.UUID) (GetSe
&i.HeartbeatEnabled,
&i.HeartbeatInterval,
&i.HeartbeatPrompt,
&i.CompactionEnabled,
&i.CompactionThreshold,
&i.ChatModelID,
&i.HeartbeatModelID,
&i.CompactionModelID,
&i.TitleModelID,
&i.SearchProviderID,
&i.MemoryProviderID,
@@ -120,16 +133,19 @@ WITH updated AS (
heartbeat_enabled = $6,
heartbeat_interval = $7,
heartbeat_prompt = $8,
chat_model_id = COALESCE($9::uuid, bots.chat_model_id),
heartbeat_model_id = COALESCE($10::uuid, bots.heartbeat_model_id),
title_model_id = COALESCE($11::uuid, bots.title_model_id),
search_provider_id = COALESCE($12::uuid, bots.search_provider_id),
memory_provider_id = COALESCE($13::uuid, bots.memory_provider_id),
tts_model_id = COALESCE($14::uuid, bots.tts_model_id),
browser_context_id = COALESCE($15::uuid, bots.browser_context_id),
compaction_enabled = $9,
compaction_threshold = $10,
chat_model_id = COALESCE($11::uuid, bots.chat_model_id),
heartbeat_model_id = COALESCE($12::uuid, bots.heartbeat_model_id),
compaction_model_id = COALESCE($13::uuid, bots.compaction_model_id),
title_model_id = COALESCE($14::uuid, bots.title_model_id),
search_provider_id = COALESCE($15::uuid, bots.search_provider_id),
memory_provider_id = COALESCE($16::uuid, bots.memory_provider_id),
tts_model_id = COALESCE($17::uuid, bots.tts_model_id),
browser_context_id = COALESCE($18::uuid, bots.browser_context_id),
updated_at = now()
WHERE bots.id = $16
RETURNING bots.id, bots.max_context_load_time, bots.max_context_tokens, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.chat_model_id, bots.heartbeat_model_id, bots.title_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.browser_context_id
WHERE bots.id = $19
RETURNING bots.id, bots.max_context_load_time, bots.max_context_tokens, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.browser_context_id
)
SELECT
updated.id AS bot_id,
@@ -141,8 +157,11 @@ SELECT
updated.heartbeat_enabled,
updated.heartbeat_interval,
updated.heartbeat_prompt,
updated.compaction_enabled,
updated.compaction_threshold,
chat_models.id AS chat_model_id,
heartbeat_models.id AS heartbeat_model_id,
compaction_models.id AS compaction_model_id,
title_models.id AS title_model_id,
search_providers.id AS search_provider_id,
memory_providers.id AS memory_provider_id,
@@ -151,6 +170,7 @@ SELECT
FROM updated
LEFT JOIN models AS chat_models ON chat_models.id = updated.chat_model_id
LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = updated.heartbeat_model_id
LEFT JOIN models AS compaction_models ON compaction_models.id = updated.compaction_model_id
LEFT JOIN models AS title_models ON title_models.id = updated.title_model_id
LEFT JOIN search_providers ON search_providers.id = updated.search_provider_id
LEFT JOIN memory_providers ON memory_providers.id = updated.memory_provider_id
@@ -159,41 +179,47 @@ LEFT JOIN browser_contexts ON browser_contexts.id = updated.browser_context_id
`
type UpsertBotSettingsParams struct {
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
ID pgtype.UUID `json:"id"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
ID pgtype.UUID `json:"id"`
}
type UpsertBotSettingsRow struct {
BotID pgtype.UUID `json:"bot_id"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
BotID pgtype.UUID `json:"bot_id"`
MaxContextLoadTime int32 `json:"max_context_load_time"`
MaxContextTokens int32 `json:"max_context_tokens"`
Language string `json:"language"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int32 `json:"heartbeat_interval"`
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
SearchProviderID pgtype.UUID `json:"search_provider_id"`
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
BrowserContextID pgtype.UUID `json:"browser_context_id"`
}
func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsParams) (UpsertBotSettingsRow, error) {
@@ -206,8 +232,11 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa
arg.HeartbeatEnabled,
arg.HeartbeatInterval,
arg.HeartbeatPrompt,
arg.CompactionEnabled,
arg.CompactionThreshold,
arg.ChatModelID,
arg.HeartbeatModelID,
arg.CompactionModelID,
arg.TitleModelID,
arg.SearchProviderID,
arg.MemoryProviderID,
@@ -226,8 +255,11 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa
&i.HeartbeatEnabled,
&i.HeartbeatInterval,
&i.HeartbeatPrompt,
&i.CompactionEnabled,
&i.CompactionThreshold,
&i.ChatModelID,
&i.HeartbeatModelID,
&i.CompactionModelID,
&i.TitleModelID,
&i.SearchProviderID,
&i.MemoryProviderID,
+119
View File
@@ -0,0 +1,119 @@
package handlers
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/accounts"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/compaction"
)
type CompactionHandler struct {
service *compaction.Service
botService *bots.Service
accountService *accounts.Service
logger *slog.Logger
}
func NewCompactionHandler(log *slog.Logger, service *compaction.Service, botService *bots.Service, accountService *accounts.Service) *CompactionHandler {
return &CompactionHandler{
service: service,
botService: botService,
accountService: accountService,
logger: log.With(slog.String("handler", "compaction")),
}
}
func (h *CompactionHandler) Register(e *echo.Echo) {
group := e.Group("/bots/:bot_id/compaction")
group.GET("/logs", h.ListLogs)
group.DELETE("/logs", h.DeleteLogs)
}
// ListLogs godoc
// @Summary List compaction logs
// @Description List compaction logs for a bot
// @Tags compaction
// @Param bot_id path string true "Bot ID"
// @Param before query string false "Before timestamp (RFC3339)"
// @Param limit query int false "Limit" default(50)
// @Success 200 {object} compaction.ListLogsResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/compaction/logs [get].
func (h *CompactionHandler) ListLogs(c echo.Context) error {
userID, err := h.requireUserID(c)
if err != nil {
return err
}
botID := strings.TrimSpace(c.Param("bot_id"))
if botID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
}
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
return err
}
var before *time.Time
if raw := strings.TrimSpace(c.QueryParam("before")); raw != "" {
t, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid before timestamp")
}
before = &t
}
limit := 50
if raw := strings.TrimSpace(c.QueryParam("limit")); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
limit = v
}
}
items, err := h.service.ListLogs(c.Request().Context(), botID, before, limit)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, compaction.ListLogsResponse{Items: items})
}
// DeleteLogs godoc
// @Summary Delete compaction logs
// @Description Delete all compaction logs for a bot
// @Tags compaction
// @Param bot_id path string true "Bot ID"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/compaction/logs [delete].
func (h *CompactionHandler) DeleteLogs(c echo.Context) error {
userID, err := h.requireUserID(c)
if err != nil {
return err
}
botID := strings.TrimSpace(c.Param("bot_id"))
if botID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
}
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
return err
}
if err := h.service.DeleteLogs(c.Request().Context(), botID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.NoContent(http.StatusNoContent)
}
func (*CompactionHandler) requireUserID(c echo.Context) (string, error) {
return RequireChannelIdentityID(c)
}
func (h *CompactionHandler) authorizeBotAccess(ctx context.Context, userID, botID string) (bots.Bot, error) {
return AuthorizeBotAccess(ctx, h.botService, h.accountService, userID, botID)
}
+10 -2
View File
@@ -482,7 +482,7 @@ func toMessageFromSinceBySessionRow(row sqlc.ListMessagesSinceBySessionRow) Mess
}
func toMessageFromActiveSinceRow(row sqlc.ListActiveMessagesSinceRow) Message {
return toMessageFields(
m := toMessageFields(
row.ID,
row.BotID,
row.SessionID,
@@ -499,10 +499,14 @@ func toMessageFromActiveSinceRow(row sqlc.ListActiveMessagesSinceRow) Message {
row.Usage,
row.CreatedAt,
)
if row.CompactID.Valid {
m.CompactID = row.CompactID.String()
}
return m
}
func toMessageFromActiveSinceBySessionRow(row sqlc.ListActiveMessagesSinceBySessionRow) Message {
return toMessageFields(
m := toMessageFields(
row.ID,
row.BotID,
row.SessionID,
@@ -519,6 +523,10 @@ func toMessageFromActiveSinceBySessionRow(row sqlc.ListActiveMessagesSinceBySess
row.Usage,
row.CreatedAt,
)
if row.CompactID.Valid {
m.CompactID = row.CompactID.String()
}
return m
}
func toMessageFromLatestRow(row sqlc.ListMessagesLatestRow) Message {
+1
View File
@@ -36,6 +36,7 @@ type Message struct {
Metadata map[string]any `json:"metadata,omitempty"`
Usage json.RawMessage `json:"usage,omitempty"`
Assets []MessageAsset `json:"assets,omitempty"`
CompactID string `json:"compact_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+65 -28
View File
@@ -70,7 +70,7 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
if err != nil {
return Settings{}, err
}
current := normalizeBotSetting(botRow.MaxContextLoadTime, botRow.MaxContextTokens, botRow.Language, allowGuest, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval)
current := normalizeBotSetting(botRow.MaxContextLoadTime, botRow.MaxContextTokens, botRow.Language, allowGuest, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval, botRow.CompactionEnabled, botRow.CompactionThreshold)
if req.MaxContextLoadTime != nil && *req.MaxContextLoadTime > 0 {
current.MaxContextLoadTime = *req.MaxContextLoadTime
}
@@ -95,6 +95,12 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
if req.HeartbeatInterval != nil && *req.HeartbeatInterval > 0 {
current.HeartbeatInterval = *req.HeartbeatInterval
}
if req.CompactionEnabled != nil {
current.CompactionEnabled = *req.CompactionEnabled
}
if req.CompactionThreshold != nil && *req.CompactionThreshold >= 0 {
current.CompactionThreshold = *req.CompactionThreshold
}
chatModelUUID := pgtype.UUID{}
if value := strings.TrimSpace(req.ChatModelID); value != "" {
modelID, err := s.resolveModelUUID(ctx, value)
@@ -111,6 +117,16 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
}
heartbeatModelUUID = modelID
}
compactionModelUUID := pgtype.UUID{}
if req.CompactionModelID != nil {
if value := strings.TrimSpace(*req.CompactionModelID); value != "" {
modelID, err := s.resolveModelUUID(ctx, value)
if err != nil {
return Settings{}, err
}
compactionModelUUID = modelID
}
}
titleModelUUID := pgtype.UUID{}
if value := strings.TrimSpace(req.TitleModelID); value != "" {
modelID, err := s.resolveModelUUID(ctx, value)
@@ -153,27 +169,31 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
}
if current.MaxContextLoadTime < math.MinInt32 || current.MaxContextLoadTime > math.MaxInt32 ||
current.MaxContextTokens < math.MinInt32 || current.MaxContextTokens > math.MaxInt32 ||
current.HeartbeatInterval < math.MinInt32 || current.HeartbeatInterval > math.MaxInt32 {
current.HeartbeatInterval < math.MinInt32 || current.HeartbeatInterval > math.MaxInt32 ||
current.CompactionThreshold < math.MinInt32 || current.CompactionThreshold > math.MaxInt32 {
return Settings{}, errors.New("settings numeric value out of int32 range")
}
updated, err := s.queries.UpsertBotSettings(ctx, sqlc.UpsertBotSettingsParams{
ID: pgID,
MaxContextLoadTime: int32(current.MaxContextLoadTime), //nolint:gosec // range validated above
MaxContextTokens: int32(current.MaxContextTokens),
Language: current.Language,
ReasoningEnabled: current.ReasoningEnabled,
ReasoningEffort: current.ReasoningEffort,
HeartbeatEnabled: current.HeartbeatEnabled,
HeartbeatInterval: int32(current.HeartbeatInterval),
HeartbeatPrompt: "",
ChatModelID: chatModelUUID,
HeartbeatModelID: heartbeatModelUUID,
TitleModelID: titleModelUUID,
SearchProviderID: searchProviderUUID,
MemoryProviderID: memoryProviderUUID,
TtsModelID: ttsModelUUID,
BrowserContextID: browserContextUUID,
ID: pgID,
MaxContextLoadTime: int32(current.MaxContextLoadTime), //nolint:gosec // range validated above
MaxContextTokens: int32(current.MaxContextTokens),
Language: current.Language,
ReasoningEnabled: current.ReasoningEnabled,
ReasoningEffort: current.ReasoningEffort,
HeartbeatEnabled: current.HeartbeatEnabled,
HeartbeatInterval: int32(current.HeartbeatInterval),
HeartbeatPrompt: "",
CompactionEnabled: current.CompactionEnabled,
CompactionThreshold: int32(current.CompactionThreshold), //nolint:gosec // range validated above
ChatModelID: chatModelUUID,
HeartbeatModelID: heartbeatModelUUID,
CompactionModelID: compactionModelUUID,
TitleModelID: titleModelUUID,
SearchProviderID: searchProviderUUID,
MemoryProviderID: memoryProviderUUID,
TtsModelID: ttsModelUUID,
BrowserContextID: browserContextUUID,
})
if err != nil {
return Settings{}, err
@@ -204,16 +224,18 @@ func (s *Service) Delete(ctx context.Context, botID string) error {
return s.setAllowGuest(ctx, botID, "", false)
}
func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, language string, allowGuest bool, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32) Settings {
func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, language string, allowGuest bool, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32, compactionEnabled bool, compactionThreshold int32) Settings {
settings := Settings{
MaxContextLoadTime: int(maxContextLoadTime),
MaxContextTokens: int(maxContextTokens),
Language: strings.TrimSpace(language),
AllowGuest: allowGuest,
ReasoningEnabled: reasoningEnabled,
ReasoningEffort: strings.TrimSpace(reasoningEffort),
HeartbeatEnabled: heartbeatEnabled,
HeartbeatInterval: int(heartbeatInterval),
MaxContextLoadTime: int(maxContextLoadTime),
MaxContextTokens: int(maxContextTokens),
Language: strings.TrimSpace(language),
AllowGuest: allowGuest,
ReasoningEnabled: reasoningEnabled,
ReasoningEffort: strings.TrimSpace(reasoningEffort),
HeartbeatEnabled: heartbeatEnabled,
HeartbeatInterval: int(heartbeatInterval),
CompactionEnabled: compactionEnabled,
CompactionThreshold: int(compactionThreshold),
}
if settings.MaxContextLoadTime <= 0 {
settings.MaxContextLoadTime = DefaultMaxContextLoadTime
@@ -230,6 +252,9 @@ func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, langu
if settings.HeartbeatInterval <= 0 {
settings.HeartbeatInterval = DefaultHeartbeatInterval
}
if settings.CompactionThreshold < 0 {
settings.CompactionThreshold = 0
}
return settings
}
@@ -251,8 +276,11 @@ func normalizeBotSettingsReadRow(row sqlc.GetSettingsByBotIDRow) Settings {
row.ReasoningEffort,
row.HeartbeatEnabled,
row.HeartbeatInterval,
row.CompactionEnabled,
row.CompactionThreshold,
row.ChatModelID,
row.HeartbeatModelID,
row.CompactionModelID,
row.TitleModelID,
row.SearchProviderID,
row.MemoryProviderID,
@@ -270,8 +298,11 @@ func normalizeBotSettingsWriteRow(row sqlc.UpsertBotSettingsRow) Settings {
row.ReasoningEffort,
row.HeartbeatEnabled,
row.HeartbeatInterval,
row.CompactionEnabled,
row.CompactionThreshold,
row.ChatModelID,
row.HeartbeatModelID,
row.CompactionModelID,
row.TitleModelID,
row.SearchProviderID,
row.MemoryProviderID,
@@ -288,21 +319,27 @@ func normalizeBotSettingsFields(
reasoningEffort string,
heartbeatEnabled bool,
heartbeatInterval int32,
compactionEnabled bool,
compactionThreshold int32,
chatModelID pgtype.UUID,
heartbeatModelID pgtype.UUID,
compactionModelID pgtype.UUID,
titleModelID pgtype.UUID,
searchProviderID pgtype.UUID,
memoryProviderID pgtype.UUID,
ttsModelID pgtype.UUID,
browserContextID pgtype.UUID,
) Settings {
settings := normalizeBotSetting(maxContextLoadTime, maxContextTokens, language, false, reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval)
settings := normalizeBotSetting(maxContextLoadTime, maxContextTokens, language, false, reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold)
if chatModelID.Valid {
settings.ChatModelID = uuid.UUID(chatModelID.Bytes).String()
}
if heartbeatModelID.Valid {
settings.HeartbeatModelID = uuid.UUID(heartbeatModelID.Bytes).String()
}
if compactionModelID.Valid {
settings.CompactionModelID = uuid.UUID(compactionModelID.Bytes).String()
}
if titleModelID.Valid {
settings.TitleModelID = uuid.UUID(titleModelID.Bytes).String()
}
+36 -30
View File
@@ -8,37 +8,43 @@ const (
)
type Settings struct {
ChatModelID string `json:"chat_model_id"`
SearchProviderID string `json:"search_provider_id"`
MemoryProviderID string `json:"memory_provider_id"`
TtsModelID string `json:"tts_model_id"`
BrowserContextID string `json:"browser_context_id"`
MaxContextLoadTime int `json:"max_context_load_time"`
MaxContextTokens int `json:"max_context_tokens"`
Language string `json:"language"`
AllowGuest bool `json:"allow_guest"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int `json:"heartbeat_interval"`
HeartbeatModelID string `json:"heartbeat_model_id"`
TitleModelID string `json:"title_model_id"`
ChatModelID string `json:"chat_model_id"`
SearchProviderID string `json:"search_provider_id"`
MemoryProviderID string `json:"memory_provider_id"`
TtsModelID string `json:"tts_model_id"`
BrowserContextID string `json:"browser_context_id"`
MaxContextLoadTime int `json:"max_context_load_time"`
MaxContextTokens int `json:"max_context_tokens"`
Language string `json:"language"`
AllowGuest bool `json:"allow_guest"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatInterval int `json:"heartbeat_interval"`
HeartbeatModelID string `json:"heartbeat_model_id"`
TitleModelID string `json:"title_model_id"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int `json:"compaction_threshold"`
CompactionModelID string `json:"compaction_model_id,omitempty"`
}
type UpsertRequest struct {
ChatModelID string `json:"chat_model_id,omitempty"`
SearchProviderID string `json:"search_provider_id,omitempty"`
MemoryProviderID string `json:"memory_provider_id,omitempty"`
TtsModelID string `json:"tts_model_id,omitempty"`
BrowserContextID string `json:"browser_context_id,omitempty"`
MaxContextLoadTime *int `json:"max_context_load_time,omitempty"`
MaxContextTokens *int `json:"max_context_tokens,omitempty"`
Language string `json:"language,omitempty"`
AllowGuest *bool `json:"allow_guest,omitempty"`
ReasoningEnabled *bool `json:"reasoning_enabled,omitempty"`
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatInterval *int `json:"heartbeat_interval,omitempty"`
HeartbeatModelID string `json:"heartbeat_model_id,omitempty"`
TitleModelID string `json:"title_model_id,omitempty"`
ChatModelID string `json:"chat_model_id,omitempty"`
SearchProviderID string `json:"search_provider_id,omitempty"`
MemoryProviderID string `json:"memory_provider_id,omitempty"`
TtsModelID string `json:"tts_model_id,omitempty"`
BrowserContextID string `json:"browser_context_id,omitempty"`
MaxContextLoadTime *int `json:"max_context_load_time,omitempty"`
MaxContextTokens *int `json:"max_context_tokens,omitempty"`
Language string `json:"language,omitempty"`
AllowGuest *bool `json:"allow_guest,omitempty"`
ReasoningEnabled *bool `json:"reasoning_enabled,omitempty"`
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatInterval *int `json:"heartbeat_interval,omitempty"`
HeartbeatModelID string `json:"heartbeat_model_id,omitempty"`
TitleModelID string `json:"title_model_id,omitempty"`
CompactionEnabled *bool `json:"compaction_enabled,omitempty"`
CompactionThreshold *int `json:"compaction_threshold,omitempty"`
CompactionModelID *string `json:"compaction_model_id,omitempty"`
}