mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat: add immediate context compaction API, UI button, and /compact slash command
- Add POST /bots/:bot_id/sessions/:session_id/compact endpoint for synchronous context compaction with fallback to chat model when no dedicated compaction model is configured - Add "Compact Now" button to session info panel in the web UI - Add /compact slash command for triggering compaction from chat - Regenerate OpenAPI spec and TypeScript SDK
This commit is contained in:
@@ -17,5 +17,6 @@ func (h *Handler) buildRegistry() *Registry {
|
||||
r.RegisterGroup(h.buildFSGroup())
|
||||
r.RegisterGroup(h.buildStatusGroup())
|
||||
r.RegisterGroup(h.buildAccessGroup())
|
||||
r.RegisterGroup(h.buildCompactGroup())
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/memohai/memoh/internal/compaction"
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
"github.com/memohai/memoh/internal/models"
|
||||
"github.com/memohai/memoh/internal/providers"
|
||||
)
|
||||
|
||||
func (h *Handler) buildCompactGroup() *CommandGroup {
|
||||
g := newCommandGroup("compact", "Compact conversation context")
|
||||
g.DefaultAction = "run"
|
||||
g.Register(SubCommand{
|
||||
Name: "run",
|
||||
Usage: "run - Compact the current session's context immediately",
|
||||
IsWrite: true,
|
||||
Handler: func(cc CommandContext) (string, error) {
|
||||
if h.compactionService == nil {
|
||||
return "Compaction service is not available.", nil
|
||||
}
|
||||
sessionID := cc.SessionID
|
||||
if sessionID == "" {
|
||||
botUUID, err := db.ParseUUID(cc.BotID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid bot id: %w", err)
|
||||
}
|
||||
latestUUID, err := h.queries.GetLatestSessionIDByBot(cc.Ctx, botUUID)
|
||||
if err != nil {
|
||||
return "No active session found.", nil
|
||||
}
|
||||
sessionID = uuid.UUID(latestUUID.Bytes).String()
|
||||
}
|
||||
|
||||
cfg, err := h.buildCompactConfig(cc, sessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := h.compactionService.RunCompactionSync(cc.Ctx, cfg); err != nil {
|
||||
return "", fmt.Errorf("compaction failed: %w", err)
|
||||
}
|
||||
return "Context compaction completed successfully.", nil
|
||||
},
|
||||
})
|
||||
return g
|
||||
}
|
||||
|
||||
func (h *Handler) buildCompactConfig(cc CommandContext, sessionID string) (compaction.TriggerConfig, error) {
|
||||
botSettings, err := h.settingsService.GetBot(cc.Ctx, cc.BotID)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
modelID := botSettings.CompactionModelID
|
||||
if modelID == "" {
|
||||
modelID = botSettings.ChatModelID
|
||||
}
|
||||
if modelID == "" {
|
||||
return compaction.TriggerConfig{}, errors.New("no compaction or chat model configured for this bot")
|
||||
}
|
||||
|
||||
compactModel, err := h.modelsService.GetByID(cc.Ctx, modelID)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, fmt.Errorf("failed to load compaction model: %w", err)
|
||||
}
|
||||
compactProvider, err := models.FetchProviderByID(cc.Ctx, h.sqlcQueries, compactModel.ProviderID)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, fmt.Errorf("failed to load provider: %w", err)
|
||||
}
|
||||
creds, err := h.providersService.ResolveModelCredentials(cc.Ctx, compactProvider)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, fmt.Errorf("failed to resolve credentials: %w", err)
|
||||
}
|
||||
|
||||
cfg := compaction.TriggerConfig{
|
||||
BotID: cc.BotID,
|
||||
SessionID: sessionID,
|
||||
ModelID: compactModel.ModelID,
|
||||
ClientType: compactProvider.ClientType,
|
||||
APIKey: creds.APIKey,
|
||||
CodexAccountID: creds.CodexAccountID,
|
||||
BaseURL: providers.ProviderConfigString(compactProvider, "base_url"),
|
||||
Ratio: 100,
|
||||
TotalInputTokens: 1,
|
||||
}
|
||||
if compactModel.Config.ContextWindow != nil && *compactModel.Config.ContextWindow > 0 {
|
||||
cfg.MaxCompactTokens = *compactModel.Config.ContextWindow * 90 / 100
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/memohai/memoh/internal/bots"
|
||||
"github.com/memohai/memoh/internal/browsercontexts"
|
||||
"github.com/memohai/memoh/internal/compaction"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
emailpkg "github.com/memohai/memoh/internal/email"
|
||||
"github.com/memohai/memoh/internal/heartbeat"
|
||||
"github.com/memohai/memoh/internal/mcp"
|
||||
@@ -56,7 +58,9 @@ type Handler struct {
|
||||
emailService *emailpkg.Service
|
||||
emailOutboxService *emailpkg.OutboxService
|
||||
heartbeatService *heartbeat.Service
|
||||
compactionService *compaction.Service
|
||||
queries CommandQueries
|
||||
sqlcQueries *sqlc.Queries
|
||||
aclEvaluator AccessEvaluator
|
||||
skillLoader SkillLoader
|
||||
containerFS ContainerFS
|
||||
@@ -124,6 +128,12 @@ func NewHandler(
|
||||
return h
|
||||
}
|
||||
|
||||
// SetCompactionService configures the compaction service for the /compact command.
|
||||
func (h *Handler) SetCompactionService(s *compaction.Service, q *sqlc.Queries) {
|
||||
h.compactionService = s
|
||||
h.sqlcQueries = q
|
||||
}
|
||||
|
||||
// topLevelCommands are standalone commands (no sub-actions) that are
|
||||
// recognised by IsCommand and listed in /help. They are handled outside
|
||||
// the regular resource-group dispatch (e.g. in the channel inbound
|
||||
|
||||
@@ -11,21 +11,42 @@ import (
|
||||
"github.com/memohai/memoh/internal/accounts"
|
||||
"github.com/memohai/memoh/internal/bots"
|
||||
"github.com/memohai/memoh/internal/compaction"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
"github.com/memohai/memoh/internal/models"
|
||||
"github.com/memohai/memoh/internal/providers"
|
||||
"github.com/memohai/memoh/internal/settings"
|
||||
)
|
||||
|
||||
type CompactionHandler struct {
|
||||
service *compaction.Service
|
||||
botService *bots.Service
|
||||
accountService *accounts.Service
|
||||
logger *slog.Logger
|
||||
service *compaction.Service
|
||||
botService *bots.Service
|
||||
accountService *accounts.Service
|
||||
settingsService *settings.Service
|
||||
modelsService *models.Service
|
||||
queries *sqlc.Queries
|
||||
providersService *providers.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCompactionHandler(log *slog.Logger, service *compaction.Service, botService *bots.Service, accountService *accounts.Service) *CompactionHandler {
|
||||
func NewCompactionHandler(
|
||||
log *slog.Logger,
|
||||
service *compaction.Service,
|
||||
botService *bots.Service,
|
||||
accountService *accounts.Service,
|
||||
settingsService *settings.Service,
|
||||
modelsService *models.Service,
|
||||
queries *sqlc.Queries,
|
||||
providersService *providers.Service,
|
||||
) *CompactionHandler {
|
||||
return &CompactionHandler{
|
||||
service: service,
|
||||
botService: botService,
|
||||
accountService: accountService,
|
||||
logger: log.With(slog.String("handler", "compaction")),
|
||||
service: service,
|
||||
botService: botService,
|
||||
accountService: accountService,
|
||||
settingsService: settingsService,
|
||||
modelsService: modelsService,
|
||||
queries: queries,
|
||||
providersService: providersService,
|
||||
logger: log.With(slog.String("handler", "compaction")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +54,7 @@ func (h *CompactionHandler) Register(e *echo.Echo) {
|
||||
group := e.Group("/bots/:bot_id/compaction")
|
||||
group.GET("/logs", h.ListLogs)
|
||||
group.DELETE("/logs", h.DeleteLogs)
|
||||
e.POST("/bots/:bot_id/sessions/:session_id/compact", h.TriggerCompact)
|
||||
}
|
||||
|
||||
// ListLogs godoc
|
||||
@@ -94,6 +116,104 @@ func (h *CompactionHandler) DeleteLogs(c echo.Context) error {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TriggerCompactResponse is the API response for triggering compaction.
|
||||
type TriggerCompactResponse struct {
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
MessageCount int `json:"message_count"`
|
||||
}
|
||||
|
||||
// TriggerCompact godoc
|
||||
// @Summary Trigger immediate context compaction
|
||||
// @Description Run context compaction synchronously for a session
|
||||
// @Tags compaction
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param session_id path string true "Session ID"
|
||||
// @Success 200 {object} TriggerCompactResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /bots/{bot_id}/sessions/{session_id}/compact [post].
|
||||
func (h *CompactionHandler) TriggerCompact(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
|
||||
}
|
||||
sessionID := strings.TrimSpace(c.Param("session_id"))
|
||||
if sessionID == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "session id is required")
|
||||
}
|
||||
|
||||
cfg, err := h.buildTriggerConfig(c.Request().Context(), botID, sessionID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
if err := h.service.RunCompactionSync(c.Request().Context(), cfg); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
logs, _, err := h.service.ListLogs(c.Request().Context(), botID, 1, 0)
|
||||
if err != nil || len(logs) == 0 {
|
||||
return c.JSON(http.StatusOK, TriggerCompactResponse{Status: "ok"})
|
||||
}
|
||||
latest := logs[0]
|
||||
return c.JSON(http.StatusOK, TriggerCompactResponse{
|
||||
Status: latest.Status,
|
||||
Summary: latest.Summary,
|
||||
MessageCount: latest.MessageCount,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CompactionHandler) buildTriggerConfig(ctx context.Context, botID, sessionID string) (compaction.TriggerConfig, error) {
|
||||
botSettings, err := h.settingsService.GetBot(ctx, botID)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, err
|
||||
}
|
||||
modelID := botSettings.CompactionModelID
|
||||
if modelID == "" {
|
||||
modelID = botSettings.ChatModelID
|
||||
}
|
||||
if modelID == "" {
|
||||
return compaction.TriggerConfig{}, echo.NewHTTPError(http.StatusBadRequest, "no compaction or chat model configured")
|
||||
}
|
||||
|
||||
compactModel, err := h.modelsService.GetByID(ctx, modelID)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, err
|
||||
}
|
||||
compactProvider, err := models.FetchProviderByID(ctx, h.queries, compactModel.ProviderID)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, err
|
||||
}
|
||||
creds, err := h.providersService.ResolveModelCredentials(ctx, compactProvider)
|
||||
if err != nil {
|
||||
return compaction.TriggerConfig{}, err
|
||||
}
|
||||
|
||||
cfg := compaction.TriggerConfig{
|
||||
BotID: botID,
|
||||
SessionID: sessionID,
|
||||
ModelID: compactModel.ModelID,
|
||||
ClientType: compactProvider.ClientType,
|
||||
APIKey: creds.APIKey,
|
||||
CodexAccountID: creds.CodexAccountID,
|
||||
BaseURL: providers.ProviderConfigString(compactProvider, "base_url"),
|
||||
Ratio: 100,
|
||||
TotalInputTokens: 1,
|
||||
}
|
||||
if compactModel.Config.ContextWindow != nil && *compactModel.Config.ContextWindow > 0 {
|
||||
cfg.MaxCompactTokens = *compactModel.Config.ContextWindow * 90 / 100
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (*CompactionHandler) requireUserID(c echo.Context) (string, error) {
|
||||
return RequireChannelIdentityID(c)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user