diff --git a/internal/app/app.go b/internal/app/app.go index 9a846db..416ab39 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -187,6 +187,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st Meals: tools.NewMealsTool(db), CRM: tools.NewCRMTool(db), Skills: tools.NewSkillsTool(db, activeProjects), + ChatHistory: tools.NewChatHistoryTool(db, activeProjects), } mcpHandler, err := mcpserver.New(cfg.MCP, logger, toolSet, activeProjects.Clear) diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 0580ab3..ef83dee 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -35,6 +35,7 @@ type ToolSet struct { Meals *tools.MealsTool CRM *tools.CRMTool Skills *tools.SkillsTool + ChatHistory *tools.ChatHistoryTool } func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onSessionClosed func(string)) (http.Handler, error) { @@ -54,6 +55,7 @@ func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onSessionCl registerMealTools, registerCRMTools, registerSkillTools, + registerChatHistoryTools, } { if err := register(server, logger, toolSet); err != nil { return nil, err @@ -514,3 +516,31 @@ func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet } return nil } + +func registerChatHistoryTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ + Name: "save_chat_history", + Description: "Save a chat session's message history for later retrieval. Stores messages with optional title, summary, channel, agent, and project metadata.", + }, toolSet.ChatHistory.SaveChatHistory); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "get_chat_history", + Description: "Retrieve a saved chat history by its UUID or session_id. Returns the full message list.", + }, toolSet.ChatHistory.GetChatHistory); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "list_chat_histories", + Description: "List saved chat histories with optional filters: project, channel, agent_id, session_id, or recent days.", + }, toolSet.ChatHistory.ListChatHistories); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "delete_chat_history", + Description: "Permanently delete a saved chat history by id.", + }, toolSet.ChatHistory.DeleteChatHistory); err != nil { + return err + } + return nil +} diff --git a/internal/store/chat_histories.go b/internal/store/chat_histories.go new file mode 100644 index 0000000..2d6d52f --- /dev/null +++ b/internal/store/chat_histories.go @@ -0,0 +1,189 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + ext "git.warky.dev/wdevs/amcs/internal/types" +) + +func (db *DB) SaveChatHistory(ctx context.Context, h ext.ChatHistory) (ext.ChatHistory, error) { + messages, err := json.Marshal(h.Messages) + if err != nil { + return ext.ChatHistory{}, fmt.Errorf("marshal messages: %w", err) + } + meta, err := json.Marshal(h.Metadata) + if err != nil { + return ext.ChatHistory{}, fmt.Errorf("marshal metadata: %w", err) + } + + row := db.pool.QueryRow(ctx, ` + insert into chat_histories + (session_id, title, channel, agent_id, project_id, messages, summary, metadata) + values ($1, $2, $3, $4, $5, $6, $7, $8) + returning id, created_at, updated_at + `, + h.SessionID, nullStr(h.Title), nullStr(h.Channel), nullStr(h.AgentID), + h.ProjectID, messages, nullStr(h.Summary), meta, + ) + + created := h + if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil { + return ext.ChatHistory{}, fmt.Errorf("insert chat history: %w", err) + } + return created, nil +} + +func (db *DB) GetChatHistory(ctx context.Context, id uuid.UUID) (ext.ChatHistory, bool, error) { + row := db.pool.QueryRow(ctx, ` + select id, session_id, title, channel, agent_id, project_id, + messages, summary, metadata, created_at, updated_at + from chat_histories where id = $1 + `, id) + h, err := scanChatHistory(row) + if err == pgx.ErrNoRows { + return ext.ChatHistory{}, false, nil + } + if err != nil { + return ext.ChatHistory{}, false, fmt.Errorf("get chat history: %w", err) + } + return h, true, nil +} + +func (db *DB) GetChatHistoryBySessionID(ctx context.Context, sessionID string) (ext.ChatHistory, bool, error) { + row := db.pool.QueryRow(ctx, ` + select id, session_id, title, channel, agent_id, project_id, + messages, summary, metadata, created_at, updated_at + from chat_histories + where session_id = $1 + order by created_at desc + limit 1 + `, sessionID) + h, err := scanChatHistory(row) + if err == pgx.ErrNoRows { + return ext.ChatHistory{}, false, nil + } + if err != nil { + return ext.ChatHistory{}, false, fmt.Errorf("get chat history by session: %w", err) + } + return h, true, nil +} + +type ListChatHistoriesFilter struct { + ProjectID *uuid.UUID + Channel string + AgentID string + SessionID string + Days int + Limit int +} + +func (db *DB) ListChatHistories(ctx context.Context, f ListChatHistoriesFilter) ([]ext.ChatHistory, error) { + limit := f.Limit + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + + conditions := []string{} + args := []any{} + + if f.ProjectID != nil { + args = append(args, *f.ProjectID) + conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args))) + } + if f.Channel != "" { + args = append(args, f.Channel) + conditions = append(conditions, fmt.Sprintf("channel = $%d", len(args))) + } + if f.AgentID != "" { + args = append(args, f.AgentID) + conditions = append(conditions, fmt.Sprintf("agent_id = $%d", len(args))) + } + if f.SessionID != "" { + args = append(args, f.SessionID) + conditions = append(conditions, fmt.Sprintf("session_id = $%d", len(args))) + } + if f.Days > 0 { + args = append(args, time.Now().UTC().AddDate(0, 0, -f.Days)) + conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args))) + } + + q := ` + select id, session_id, title, channel, agent_id, project_id, + messages, summary, metadata, created_at, updated_at + from chat_histories` + if len(conditions) > 0 { + q += " where " + strings.Join(conditions, " and ") + } + args = append(args, limit) + q += fmt.Sprintf(" order by created_at desc limit $%d", len(args)) + + rows, err := db.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list chat histories: %w", err) + } + defer rows.Close() + + var result []ext.ChatHistory + for rows.Next() { + h, err := scanChatHistory(rows) + if err != nil { + return nil, fmt.Errorf("scan chat history: %w", err) + } + result = append(result, h) + } + return result, rows.Err() +} + +func (db *DB) DeleteChatHistory(ctx context.Context, id uuid.UUID) (bool, error) { + tag, err := db.pool.Exec(ctx, `delete from chat_histories where id = $1`, id) + if err != nil { + return false, fmt.Errorf("delete chat history: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanChatHistory(row rowScanner) (ext.ChatHistory, error) { + var h ext.ChatHistory + var title, channel, agentID, summary *string + var messagesJSON, metaJSON []byte + + if err := row.Scan( + &h.ID, &h.SessionID, &title, &channel, &agentID, &h.ProjectID, + &messagesJSON, &summary, &metaJSON, &h.CreatedAt, &h.UpdatedAt, + ); err != nil { + return ext.ChatHistory{}, err + } + + h.Title = strVal(title) + h.Channel = strVal(channel) + h.AgentID = strVal(agentID) + h.Summary = strVal(summary) + + if err := json.Unmarshal(messagesJSON, &h.Messages); err != nil { + return ext.ChatHistory{}, fmt.Errorf("unmarshal messages: %w", err) + } + if err := json.Unmarshal(metaJSON, &h.Metadata); err != nil { + return ext.ChatHistory{}, fmt.Errorf("unmarshal metadata: %w", err) + } + if h.Messages == nil { + h.Messages = []ext.ChatMessage{} + } + if h.Metadata == nil { + h.Metadata = map[string]any{} + } + return h, nil +} diff --git a/internal/tools/chat_history.go b/internal/tools/chat_history.go new file mode 100644 index 0000000..b535b4f --- /dev/null +++ b/internal/tools/chat_history.go @@ -0,0 +1,178 @@ +package tools + +import ( + "context" + "strings" + + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.warky.dev/wdevs/amcs/internal/session" + "git.warky.dev/wdevs/amcs/internal/store" + ext "git.warky.dev/wdevs/amcs/internal/types" +) + +type ChatHistoryTool struct { + store *store.DB + sessions *session.ActiveProjects +} + +func NewChatHistoryTool(db *store.DB, sessions *session.ActiveProjects) *ChatHistoryTool { + return &ChatHistoryTool{store: db, sessions: sessions} +} + +// save_chat_history + +type SaveChatHistoryInput struct { + SessionID string `json:"session_id" jsonschema:"unique identifier for the chat session (e.g. OpenClaw session id or thread id)"` + Title string `json:"title,omitempty" jsonschema:"optional human-readable title for this conversation"` + Channel string `json:"channel,omitempty" jsonschema:"optional channel name (e.g. telegram, discord, signal)"` + AgentID string `json:"agent_id,omitempty" jsonschema:"optional agent identifier (e.g. claude, codex, main)"` + Project string `json:"project,omitempty" jsonschema:"optional project name or id; falls back to active session project"` + Messages []ext.ChatMessage `json:"messages" jsonschema:"ordered list of messages in the conversation"` + Summary string `json:"summary,omitempty" jsonschema:"optional brief summary of the conversation"` + Metadata map[string]any `json:"metadata,omitempty" jsonschema:"optional arbitrary key-value metadata"` +} + +type SaveChatHistoryOutput struct { + ChatHistory ext.ChatHistory `json:"chat_history"` +} + +func (t *ChatHistoryTool) SaveChatHistory(ctx context.Context, req *mcp.CallToolRequest, in SaveChatHistoryInput) (*mcp.CallToolResult, SaveChatHistoryOutput, error) { + if strings.TrimSpace(in.SessionID) == "" { + return nil, SaveChatHistoryOutput{}, errRequiredField("session_id") + } + if len(in.Messages) == 0 { + return nil, SaveChatHistoryOutput{}, errRequiredField("messages") + } + + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) + if err != nil { + return nil, SaveChatHistoryOutput{}, err + } + + h := ext.ChatHistory{ + SessionID: strings.TrimSpace(in.SessionID), + Title: strings.TrimSpace(in.Title), + Channel: strings.TrimSpace(in.Channel), + AgentID: strings.TrimSpace(in.AgentID), + Messages: in.Messages, + Summary: strings.TrimSpace(in.Summary), + Metadata: in.Metadata, + } + if h.Metadata == nil { + h.Metadata = map[string]any{} + } + if project != nil { + h.ProjectID = &project.ID + } + + saved, err := t.store.SaveChatHistory(ctx, h) + if err != nil { + return nil, SaveChatHistoryOutput{}, err + } + return nil, SaveChatHistoryOutput{ChatHistory: saved}, nil +} + +// get_chat_history + +type GetChatHistoryInput struct { + ID string `json:"id,omitempty" jsonschema:"UUID of the saved chat history"` + SessionID string `json:"session_id,omitempty" jsonschema:"original session_id — returns the most recent history for that session"` +} + +type GetChatHistoryOutput struct { + ChatHistory *ext.ChatHistory `json:"chat_history"` +} + +func (t *ChatHistoryTool) GetChatHistory(ctx context.Context, _ *mcp.CallToolRequest, in GetChatHistoryInput) (*mcp.CallToolResult, GetChatHistoryOutput, error) { + if in.ID == "" && in.SessionID == "" { + return nil, GetChatHistoryOutput{}, errRequiredField("id or session_id") + } + + if in.ID != "" { + id, err := uuid.Parse(in.ID) + if err != nil { + return nil, GetChatHistoryOutput{}, errInvalidField("id", "invalid id", "must be a valid UUID") + } + h, found, err := t.store.GetChatHistory(ctx, id) + if err != nil { + return nil, GetChatHistoryOutput{}, err + } + if !found { + return nil, GetChatHistoryOutput{}, nil + } + return nil, GetChatHistoryOutput{ChatHistory: &h}, nil + } + + h, found, err := t.store.GetChatHistoryBySessionID(ctx, in.SessionID) + if err != nil { + return nil, GetChatHistoryOutput{}, err + } + if !found { + return nil, GetChatHistoryOutput{}, nil + } + return nil, GetChatHistoryOutput{ChatHistory: &h}, nil +} + +// list_chat_histories + +type ListChatHistoriesInput struct { + Project string `json:"project,omitempty" jsonschema:"filter by project name or id"` + Channel string `json:"channel,omitempty" jsonschema:"filter by channel (e.g. telegram, discord)"` + AgentID string `json:"agent_id,omitempty" jsonschema:"filter by agent id"` + SessionID string `json:"session_id,omitempty" jsonschema:"filter by session_id"` + Days int `json:"days,omitempty" jsonschema:"only include histories from the last N days"` + Limit int `json:"limit,omitempty" jsonschema:"maximum number of results to return (default 20, max 200)"` +} + +type ListChatHistoriesOutput struct { + ChatHistories []ext.ChatHistory `json:"chat_histories"` +} + +func (t *ChatHistoryTool) ListChatHistories(ctx context.Context, req *mcp.CallToolRequest, in ListChatHistoriesInput) (*mcp.CallToolResult, ListChatHistoriesOutput, error) { + filter := store.ListChatHistoriesFilter{ + Channel: strings.TrimSpace(in.Channel), + AgentID: strings.TrimSpace(in.AgentID), + SessionID: strings.TrimSpace(in.SessionID), + Days: in.Days, + Limit: in.Limit, + } + + if in.Project != "" { + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) + if err != nil { + return nil, ListChatHistoriesOutput{}, err + } + if project != nil { + filter.ProjectID = &project.ID + } + } + + histories, err := t.store.ListChatHistories(ctx, filter) + if err != nil { + return nil, ListChatHistoriesOutput{}, err + } + if histories == nil { + histories = []ext.ChatHistory{} + } + return nil, ListChatHistoriesOutput{ChatHistories: histories}, nil +} + +// delete_chat_history + +type DeleteChatHistoryInput struct { + ID uuid.UUID `json:"id" jsonschema:"UUID of the chat history to delete"` +} + +type DeleteChatHistoryOutput struct { + Deleted bool `json:"deleted"` +} + +func (t *ChatHistoryTool) DeleteChatHistory(ctx context.Context, _ *mcp.CallToolRequest, in DeleteChatHistoryInput) (*mcp.CallToolResult, DeleteChatHistoryOutput, error) { + deleted, err := t.store.DeleteChatHistory(ctx, in.ID) + if err != nil { + return nil, DeleteChatHistoryOutput{}, err + } + return nil, DeleteChatHistoryOutput{Deleted: deleted}, nil +} diff --git a/internal/types/extensions.go b/internal/types/extensions.go index ecfcd9b..ad48872 100644 --- a/internal/types/extensions.go +++ b/internal/types/extensions.go @@ -236,3 +236,24 @@ type AgentGuardrail struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// Chat Histories + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ChatHistory struct { + ID uuid.UUID `json:"id"` + SessionID string `json:"session_id"` + Title string `json:"title,omitempty"` + Channel string `json:"channel,omitempty"` + AgentID string `json:"agent_id,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` + Messages []ChatMessage `json:"messages"` + Summary string `json:"summary,omitempty"` + Metadata map[string]any `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/migrations/018_chat_histories.sql b/migrations/018_chat_histories.sql new file mode 100644 index 0000000..fbe3b28 --- /dev/null +++ b/migrations/018_chat_histories.sql @@ -0,0 +1,26 @@ +-- Migration: 018_chat_histories +-- Adds a dedicated table for saving and retrieving agent chat histories. + +CREATE TABLE IF NOT EXISTS chat_histories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id TEXT NOT NULL, + title TEXT, + channel TEXT, + agent_id TEXT, + project_id UUID REFERENCES projects(id) ON DELETE SET NULL, + messages JSONB NOT NULL DEFAULT '[]', + summary TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_chat_histories_session_id ON chat_histories(session_id); +CREATE INDEX IF NOT EXISTS idx_chat_histories_project_id ON chat_histories(project_id) WHERE project_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_chat_histories_channel ON chat_histories(channel) WHERE channel IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_chat_histories_agent_id ON chat_histories(agent_id) WHERE agent_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_chat_histories_created_at ON chat_histories(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_chat_histories_fts + ON chat_histories + USING GIN (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(summary, '')));