Files
Memoh/db/migrations/0001_init.up.sql
T
Acbox Liu 7d7d0e4b51 refactor: introduce multi-session chat support (#session) (#267)
* refactor: introduce multi-session chat support (#session)

Replace the single-context-per-bot model with multiple chat sessions.

Database:
- Add bot_sessions table (route_id, channel_type, title, metadata, soft delete)
- Migrate bot_history_messages from (route_id, channel_type) to session_id
- Add active_session_id to bot_channel_routes
- Migration 0036 handles data migration from existing messages

Backend:
- New internal/session service for session CRUD
- Update message service/types to use session_id instead of route_id
- Update conversation flow (resolver, history, store) for session context
- Channel inbound auto-creates/retrieves active session via SessionEnsurer
- New REST endpoints: /bots/:bot_id/sessions (CRUD)
- WebSocket and message handlers accept optional session_id
- Wire session service into FX dependency graph (agent + memoh)

Frontend:
- Refactor chat store: sessions replaces chats, sessionId replaces chatId
- Session-aware message loading, sending, and pagination
- WebSocket sends include session_id
- New session sidebar component with select/delete
- Chat area header shows active session title + new session button
- API layer updated: fetchSessions, createSession, deleteSession
- i18n strings for session management (en + zh)

SDK:
- Regenerated TypeScript SDK and Swagger docs with session endpoints

* fix: update tests for session refactoring (RouteID → SessionID)

Remove references to removed RouteID and Platform fields from
PersistInput/Message in channel_test.go and service_integration_test.go.

* fix: restore accidentally deleted SDK files and guard migration 0032

- Restore packages/sdk/src/container-stream.ts and extra/index.ts that
  were accidentally removed during SDK regeneration
- Wrap migration 0032 route_id index creation in a column existence check
  to avoid failure on fresh databases where 0001_init.up.sql no longer
  has route_id

* fix: guard migration 0036 data steps for fresh databases

Wrap steps 3-7 (which reference route_id/channel_type on
bot_history_messages) in a column existence check so the migration
is safe on fresh databases where 0001_init.up.sql already reflects
the final schema without those columns.

* feat: add title model setting and auto-generate session titles on user input

- Add title_model_id to bots table (migration 0037) and bot settings API
- Implement async title generation triggered at user message time (not after
  assistant response) for faster title availability
- Publish session_title_updated events via SSE event hub for real-time
  frontend updates without page refresh
- Fix SSE message event parsing: use direct JSON.parse instead of
  normalizeStreamEvent which silently dropped non-chat-stream event types
- Add title model selector in bot settings UI with i18n support

* fix: session-scoped message filtering and URL-based chat routing

- Filter realtime SSE messages by session_id to prevent cross-session
  message leakage after page refresh
- Add /chat/:sessionId? route with bidirectional URL ↔ store sync
- Visiting /chat shows a clean state with no bot or session pre-selected
- Visiting /chat/:sessionId loads the specific session directly
- Session switches from sidebar automatically update the URL
- Fix stale RouteID field in dedupe test (removed during session refactor)

* fix: skip cross-channel stream events to prevent session leakage

The bot-level web stream pushes events from all channels (Telegram,
Discord, etc.) without session_id context. Previously these were
rendered inline in the current chat view regardless of session.

Now cross-channel events are ignored in handleLocalStreamEvent;
persisted messages arrive via the SSE message events stream with
proper session_id filtering through appendRealtimeMessage.

* feat: show IM avatars and platform badges on session sidebar

- Add sender_avatar_url to route metadata from identity resolution
- Resolve group avatar and handle via directory adapter for group chats
- JOIN bot_channel_routes in ListSessionsByBot to return route metadata
- Display avatar with ChannelBadge on IM session items (group avatar
  for groups, sender avatar for private chats)
- Show @groupname or @username as session sub-label

* fix: clean up RunConfig unused fields, fix skill system and copy bug

- Remove unused RunConfig fields: Tools, Channels, CurrentChannel,
  ActiveContextTime
- Remove unused SessionContext fields: DisplayName, ConversationType
- Fix EnabledSkillNames copy bug: make([]string, 0, n) + copy copies
  zero elements; changed to make([]string, n)
- Fix prepareRunConfig dead code: remove no-op loop over
  CurrentPlatform runes; compute supportsImageInput from model's
  InputModalities
- Fix EnabledSkills always nil in system prompt: resolve enabled skill
  entries from EnabledSkillNames + Skills
- Fix use_skill tool returning empty response: now returns full skill
  content (description + instructions) so LLM gets it in the same turn
- Skip use_skill tool registration when no skills are available
- Conditionally render Skills section in system prompt (hidden when
  no skills exist)

* feat: add session type field and bind sessions to heartbeat/schedule executions

- Add `type` column to `bot_sessions` (chat | heartbeat | schedule)
- Add `session_id` to `bot_heartbeat_logs` for per-execution session tracking
- Create `schedule_logs` table binding schedule_id + session_id
- Heartbeat and schedule runs now create independent sessions and persist
  agent messages via storeRound, enabling full conversation replay
- Add schedule logs API endpoints (list by bot, list by schedule, delete)
- Update Triggerer interfaces to return TriggerResult with status/usage/model

* refactor: modular system prompts per session type (chat/heartbeat/schedule)

Split the monolithic system.md into three type-specific system prompts
with shared fragments via {{include:_xxx}} syntax, so each session type
gets a focused prompt without irrelevant instructions.

* fix: prevent message duplication after task completion

message_created events from Persist() had an empty platform field because
toMessageFromCreate() didn't extract it from the session. This caused
appendRealtimeMessage to fail the platform === 'web' guard, and
hasMessageWithId to fail because local IDs differ from server UUIDs,
resulting in all messages being appended as duplicates.

- Extract platform from metadata in toMessageFromCreate so published events
  carry the correct value
- Pass channel_type: 'web' when creating sessions from the web frontend so
  List queries return the correct platform via the session JOIN

* fix: use per-message usage from SDK instead of misaligned step-level usages

Previously, token usage was stored via a separate per-step usages array
that didn't align with messages (off-by-one from prepending user message,
step count != message count). This caused:
- User messages incorrectly receiving usage data
- Usage values shifted across messages in multi-step rounds
- Last assistant message getting the accumulated total instead of its own step usage
- InputTokenDetails/OutputTokenDetails lost during manual accumulation

Now each sdk.Message carries its own per-step Usage (set by the SDK in
buildStepMessages), which is extracted in sdkMessagesToModelMessages and
stored directly via ModelMessage.Usage. The storeRound/storeMessages path
no longer needs external usage/usages parameters.

Also fixes the totalUsage accumulation in runStream to include all detail
fields (InputTokenDetails, OutputTokenDetails).

* feat: add /new slash command to create a new active session from IM channels

Users in Telegram/Discord/Feishu can now send /new to start a fresh
conversation, resetting the session context for the current chat thread.
The command resolves the channel route, creates a new session, sets it as
the active session on the route, and replies with a confirmation message.

* feat: distinguish heartbeat and schedule sessions with dedicated icons in sidebar

Heartbeat sessions show a heart-pulse icon (rose), schedule sessions
show a clock icon (amber), and both display a type label beneath the
session title.

* refactor: remove enabledSkills system prompt injection, keep sorted skill listing

use_skill now returns skill content directly as tool output, so there is
no need to inject enabled skill body text into the system prompt. Remove
the entire enabledSkills tracking chain (RunConfig.EnabledSkillNames,
StreamEvent.Skills, GenerateResult.Skills, ChatRequest/Response.Skills,
enableSkill closures in runStream/runGenerate, prepareRunConfig matching).

Keep a lightweight skills listing (name + description only) in the system
prompt so the model knows which skills are available. Sort entries by name
to guarantee deterministic ordering and maximize KV cache reuse.

* refactor: remove inbox system, persist passive messages directly to history

Replace the bot_inbox table and service with direct writes to
bot_history_messages for group conversations where the bot is not
@mentioned. Trigger-path messages continue to be persisted after the
agent responds (unchanged).

- Drop bot_inbox table and max_inbox_items column (migration 0039)
- Delete internal/inbox/, handlers/inbox.go, command/inbox.go,
  agent/tools/inbox.go and the MCP message provider
- Add persistPassiveMessage() in channel inbound to write user
  messages into the active session immediately
- Rewrite ListObservedConversationsByChannelIdentity to query
  bot_history_messages + bot_sessions instead of bot_inbox
- Extract shared send/react logic into internal/messaging/executor.go;
  agent/tools/message.go is now a thin SDK adapter
- Clean up all inbox references from agent prompts, flow resolver,
  email trigger, settings, commands, DI wiring, and frontend
- Regenerate sqlc, swagger, and SDK

* feat: add list_sessions and search_messages agent tools

Provide agents with the ability to query session metadata and search
message history across all sessions. search_messages supports filtering
by time range, keyword (JSONB-aware ILIKE), session, contact, and role,
with a default 7-day lookback when no start_time is given.

* feat: inject last_heartbeat time and improve heartbeat search guidance

Query the previous heartbeat's started_at timestamp and pass it through
TriggerPayload into the heartbeat prompt template. Update system prompt
and HEARTBEAT.md checklist to guide agents to use search_messages with
start_time=last_heartbeat for efficient cross-session message review.

* fix: pass BridgeProvider to FSClient and store full heartbeat prompt

FSClient was always created with nil provider, causing all container
file reads (IDENTITY.md, SOUL.md, MEMORY.md, HEARTBEAT.md, etc.) to
silently return empty strings. Expose Agent.BridgeProvider() and wire
it into Resolver. Also fix heartbeat trigger to store the full prompt
template as the user message instead of the literal "heartbeat" string.

* feat: add line numbers to container file read output

Move line-number formatting from the bridge gRPC server to the agent
tool layer so that the raw content stored and transmitted via gRPC
remains clean, while the read_file tool output includes numbered lines
for easier reference by the agent.

* chore(deps): update twilight-ai to v0.3.2

* fix: lint, test
2026-03-21 15:57:22 +08:00

625 lines
26 KiB
SQL

CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
CREATE TYPE user_role AS ENUM ('member', 'admin');
END IF;
END
$$;
-- users: Memoh user principal
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT,
email TEXT,
password_hash TEXT,
role user_role NOT NULL DEFAULT 'member',
display_name TEXT,
avatar_url TEXT,
data_root TEXT,
last_login_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT true,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT users_email_unique UNIQUE (email),
CONSTRAINT users_username_unique UNIQUE (username)
);
-- channel_identities: unified inbound identity subject
CREATE TABLE IF NOT EXISTS channel_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
channel_type TEXT NOT NULL,
channel_subject_id TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT channel_identities_channel_type_subject_unique UNIQUE (channel_type, channel_subject_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_identities_user_id ON channel_identities(user_id);
-- user_channel_bindings: outbound delivery config
CREATE TABLE IF NOT EXISTS user_channel_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_type TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT user_channel_bindings_unique UNIQUE (user_id, channel_type)
);
CREATE INDEX IF NOT EXISTS idx_user_channel_bindings_user_id ON user_channel_bindings(user_id);
CREATE TABLE IF NOT EXISTS llm_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT llm_providers_name_unique UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS search_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
provider TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT search_providers_name_unique UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_id TEXT NOT NULL,
name TEXT,
llm_provider_id UUID NOT NULL REFERENCES llm_providers(id) ON DELETE CASCADE,
client_type TEXT,
dimensions INTEGER,
input_modalities TEXT[] NOT NULL DEFAULT ARRAY['text']::TEXT[],
supports_reasoning BOOLEAN NOT NULL DEFAULT false,
type TEXT NOT NULL DEFAULT 'chat',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT models_provider_model_id_unique UNIQUE (llm_provider_id, model_id),
CONSTRAINT models_type_check CHECK (type IN ('chat', 'embedding')),
CONSTRAINT models_dimensions_check CHECK (type != 'embedding' OR dimensions IS NOT NULL),
CONSTRAINT models_client_type_check CHECK (client_type IS NULL OR client_type IN ('openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai')),
CONSTRAINT models_chat_client_type_check CHECK (type != 'chat' OR client_type IS NOT NULL)
);
CREATE TABLE IF NOT EXISTS model_variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_uuid UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
variant_id TEXT NOT NULL,
weight INTEGER NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_model_variants_model_uuid ON model_variants(model_uuid);
CREATE INDEX IF NOT EXISTS idx_model_variants_variant_id ON model_variants(variant_id);
CREATE TABLE IF NOT EXISTS memory_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
provider TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
is_default BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT memory_providers_name_unique UNIQUE (name)
);
-- tts_providers: pluggable TTS service backends
CREATE TABLE IF NOT EXISTS tts_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
provider TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tts_providers_name_unique UNIQUE (name)
);
-- tts_models: available models per TTS provider with per-model configuration
CREATE TABLE IF NOT EXISTS tts_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_id TEXT NOT NULL,
name TEXT,
tts_provider_id UUID NOT NULL REFERENCES tts_providers(id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tts_models_provider_id ON tts_models(tts_provider_id);
CREATE TABLE IF NOT EXISTS browser_contexts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS bots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
status TEXT NOT NULL DEFAULT 'ready',
max_context_load_time INTEGER NOT NULL DEFAULT 1440,
max_context_tokens INTEGER NOT NULL DEFAULT 0,
language TEXT NOT NULL DEFAULT 'auto',
reasoning_enabled BOOLEAN NOT NULL DEFAULT false,
reasoning_effort TEXT NOT NULL DEFAULT 'medium',
chat_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
search_provider_id UUID REFERENCES search_providers(id) ON DELETE SET NULL,
memory_provider_id UUID REFERENCES memory_providers(id) ON DELETE SET NULL,
heartbeat_enabled BOOLEAN NOT NULL DEFAULT false,
heartbeat_interval INTEGER NOT NULL DEFAULT 30,
heartbeat_prompt TEXT NOT NULL DEFAULT '',
heartbeat_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
title_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
tts_model_id UUID REFERENCES tts_models(id) ON DELETE SET NULL,
browser_context_id UUID REFERENCES browser_contexts(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT bots_type_check CHECK (type IN ('personal', 'public')),
CONSTRAINT bots_status_check CHECK (status IN ('creating', 'ready', 'deleting')),
CONSTRAINT bots_reasoning_effort_check CHECK (reasoning_effort IN ('low', 'medium', 'high'))
);
CREATE INDEX IF NOT EXISTS idx_bots_owner_user_id ON bots(owner_user_id);
CREATE TABLE IF NOT EXISTS bot_acl_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
action TEXT NOT NULL,
effect TEXT NOT NULL,
subject_kind TEXT NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
channel_identity_id UUID REFERENCES channel_identities(id) ON DELETE CASCADE,
source_channel TEXT,
source_conversation_type TEXT,
source_conversation_id TEXT,
source_thread_id TEXT,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT bot_acl_rules_action_check CHECK (action IN ('chat.trigger')),
CONSTRAINT bot_acl_rules_effect_check CHECK (effect IN ('allow', 'deny')),
CONSTRAINT bot_acl_rules_subject_kind_check CHECK (subject_kind IN ('guest_all', 'user', 'channel_identity')),
CONSTRAINT bot_acl_rules_source_conversation_type_check CHECK (
source_conversation_type IS NULL OR source_conversation_type IN ('private', 'group', 'thread')
),
CONSTRAINT bot_acl_rules_source_scope_check CHECK (
(source_conversation_id IS NULL AND source_thread_id IS NULL)
OR source_channel IS NOT NULL
),
CONSTRAINT bot_acl_rules_source_thread_check CHECK (
source_thread_id IS NULL OR source_conversation_id IS NOT NULL
),
CONSTRAINT bot_acl_rules_subject_value_check CHECK (
(subject_kind = 'guest_all' AND user_id IS NULL AND channel_identity_id IS NULL) OR
(subject_kind = 'user' AND user_id IS NOT NULL AND channel_identity_id IS NULL) OR
(subject_kind = 'channel_identity' AND user_id IS NULL AND channel_identity_id IS NOT NULL)
),
CONSTRAINT bot_acl_rules_unique_user UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, user_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id
),
CONSTRAINT bot_acl_rules_unique_channel_identity UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, channel_identity_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id
)
);
CREATE INDEX IF NOT EXISTS idx_bot_acl_rules_bot_id ON bot_acl_rules(bot_id);
CREATE INDEX IF NOT EXISTS idx_bot_acl_rules_user_id ON bot_acl_rules(user_id);
CREATE INDEX IF NOT EXISTS idx_bot_acl_rules_channel_identity_id ON bot_acl_rules(channel_identity_id);
CREATE TABLE IF NOT EXISTS mcp_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT true,
status TEXT NOT NULL DEFAULT 'unknown',
tools_cache JSONB NOT NULL DEFAULT '[]'::jsonb,
last_probed_at TIMESTAMPTZ,
status_message TEXT NOT NULL DEFAULT '',
auth_type TEXT NOT NULL DEFAULT 'none',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT mcp_connections_type_check CHECK (type IN ('stdio', 'http', 'sse')),
CONSTRAINT mcp_connections_unique UNIQUE (bot_id, name)
);
CREATE INDEX IF NOT EXISTS idx_mcp_connections_bot_id ON mcp_connections(bot_id);
CREATE TABLE IF NOT EXISTS mcp_oauth_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
connection_id UUID NOT NULL UNIQUE REFERENCES mcp_connections(id) ON DELETE CASCADE,
resource_metadata_url TEXT NOT NULL DEFAULT '',
authorization_server_url TEXT NOT NULL DEFAULT '',
authorization_endpoint TEXT NOT NULL DEFAULT '',
token_endpoint TEXT NOT NULL DEFAULT '',
registration_endpoint TEXT NOT NULL DEFAULT '',
scopes_supported TEXT[] NOT NULL DEFAULT '{}',
client_id TEXT NOT NULL DEFAULT '',
client_secret TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
token_type TEXT NOT NULL DEFAULT 'Bearer',
expires_at TIMESTAMPTZ,
scope TEXT NOT NULL DEFAULT '',
pkce_code_verifier TEXT NOT NULL DEFAULT '',
state_param TEXT NOT NULL DEFAULT '',
resource_uri TEXT NOT NULL DEFAULT '',
redirect_uri TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_connection_id ON mcp_oauth_tokens(connection_id);
-- Bot history is bot-scoped (one history container per bot).
CREATE TABLE IF NOT EXISTS bot_channel_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
channel_type TEXT NOT NULL,
credentials JSONB NOT NULL DEFAULT '{}'::jsonb,
external_identity TEXT,
self_identity JSONB NOT NULL DEFAULT '{}'::jsonb,
routing JSONB NOT NULL DEFAULT '{}'::jsonb,
capabilities JSONB NOT NULL DEFAULT '{}'::jsonb,
disabled BOOLEAN NOT NULL DEFAULT false,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT bot_channel_unique UNIQUE (bot_id, channel_type)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_bot_channel_external_identity
ON bot_channel_configs(channel_type, external_identity);
CREATE INDEX IF NOT EXISTS idx_bot_channel_bot_id ON bot_channel_configs(bot_id);
-- channel_identity_bind_codes: one-time codes for channel identity->user linking
CREATE TABLE IF NOT EXISTS channel_identity_bind_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT NOT NULL,
issued_by_user_id UUID NOT NULL REFERENCES users(id),
channel_type TEXT,
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
used_by_channel_identity_id UUID REFERENCES channel_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT channel_identity_bind_codes_token_unique UNIQUE (token)
);
CREATE INDEX IF NOT EXISTS idx_channel_identity_bind_codes_channel_type ON channel_identity_bind_codes(channel_type);
-- bot_channel_routes: route mapping for inbound channel threads to bot history.
CREATE TABLE IF NOT EXISTS bot_channel_routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
channel_type TEXT NOT NULL,
channel_config_id UUID REFERENCES bot_channel_configs(id) ON DELETE SET NULL,
external_conversation_id TEXT NOT NULL,
external_thread_id TEXT,
conversation_type TEXT,
default_reply_target TEXT,
active_session_id UUID,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_bot_channel_routes_unique
ON bot_channel_routes (bot_id, channel_type, external_conversation_id, COALESCE(external_thread_id, ''));
CREATE INDEX IF NOT EXISTS idx_bot_channel_routes_bot ON bot_channel_routes(bot_id);
-- bot_sessions: chat sessions within a bot, optionally linked to a channel route.
CREATE TABLE IF NOT EXISTS bot_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
route_id UUID REFERENCES bot_channel_routes(id) ON DELETE SET NULL,
channel_type TEXT,
type TEXT NOT NULL DEFAULT 'chat' CHECK (type IN ('chat', 'heartbeat', 'schedule')),
title TEXT NOT NULL DEFAULT '',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_id ON bot_sessions(bot_id);
CREATE INDEX IF NOT EXISTS idx_bot_sessions_route_id ON bot_sessions(route_id);
CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_active ON bot_sessions(bot_id, deleted_at);
-- Add FK from routes to sessions (deferred to avoid circular dependency during CREATE).
ALTER TABLE bot_channel_routes
ADD CONSTRAINT fk_bot_channel_routes_active_session
FOREIGN KEY (active_session_id) REFERENCES bot_sessions(id) ON DELETE SET NULL;
-- bot_history_messages: unified message history under bot scope.
CREATE TABLE IF NOT EXISTS bot_history_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
session_id UUID REFERENCES bot_sessions(id) ON DELETE SET NULL,
sender_channel_identity_id UUID REFERENCES channel_identities(id),
sender_account_user_id UUID REFERENCES users(id),
source_message_id TEXT,
source_reply_to_message_id TEXT,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system', 'tool')),
content JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
usage JSONB,
model_id UUID REFERENCES models(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_bot_created ON bot_history_messages(bot_id, created_at);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_session
ON bot_history_messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_session_source
ON bot_history_messages(session_id, source_message_id);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_session_reply
ON bot_history_messages(session_id, source_reply_to_message_id);
CREATE TABLE IF NOT EXISTS containers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
container_id TEXT NOT NULL,
container_name TEXT NOT NULL,
image TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'created',
namespace TEXT NOT NULL DEFAULT 'default',
auto_start BOOLEAN NOT NULL DEFAULT true,
container_path TEXT NOT NULL DEFAULT '/data',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_started_at TIMESTAMPTZ,
last_stopped_at TIMESTAMPTZ,
CONSTRAINT containers_container_id_unique UNIQUE (container_id),
CONSTRAINT containers_container_name_unique UNIQUE (container_name)
);
CREATE INDEX IF NOT EXISTS idx_containers_bot_id ON containers(bot_id);
CREATE TABLE IF NOT EXISTS snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
container_id TEXT NOT NULL REFERENCES containers(container_id) ON DELETE CASCADE,
runtime_snapshot_name TEXT NOT NULL,
display_name TEXT,
parent_runtime_snapshot_name TEXT,
snapshotter TEXT NOT NULL,
source TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_snapshots_container_runtime_name
ON snapshots(container_id, runtime_snapshot_name);
CREATE INDEX IF NOT EXISTS idx_snapshots_container_created_at
ON snapshots(container_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_snapshots_runtime_name
ON snapshots(runtime_snapshot_name);
CREATE TABLE IF NOT EXISTS container_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
container_id TEXT NOT NULL REFERENCES containers(container_id) ON DELETE CASCADE,
snapshot_id UUID NOT NULL REFERENCES snapshots(id) ON DELETE RESTRICT,
version INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (container_id, version)
);
CREATE INDEX IF NOT EXISTS idx_container_versions_container_id ON container_versions(container_id);
CREATE INDEX IF NOT EXISTS idx_container_versions_snapshot_id ON container_versions(snapshot_id);
CREATE TABLE IF NOT EXISTS lifecycle_events (
id TEXT PRIMARY KEY,
container_id TEXT NOT NULL REFERENCES containers(container_id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lifecycle_events_container_id ON lifecycle_events(container_id);
CREATE INDEX IF NOT EXISTS idx_lifecycle_events_event_type ON lifecycle_events(event_type);
CREATE TABLE IF NOT EXISTS schedule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT NOT NULL,
pattern TEXT NOT NULL,
max_calls INTEGER,
current_calls INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
enabled BOOLEAN NOT NULL DEFAULT true,
command TEXT NOT NULL,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_schedule_bot_id ON schedule(bot_id);
CREATE INDEX IF NOT EXISTS idx_schedule_enabled ON schedule(enabled);
CREATE TABLE IF NOT EXISTS subagents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
skills JSONB NOT NULL DEFAULT '[]'::jsonb,
usage JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT subagents_name_unique UNIQUE (bot_id, name)
);
CREATE INDEX IF NOT EXISTS idx_subagents_bot_id ON subagents(bot_id);
CREATE INDEX IF NOT EXISTS idx_subagents_deleted ON subagents(deleted);
-- storage_providers: pluggable object storage backends
CREATE TABLE IF NOT EXISTS storage_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
provider TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT storage_providers_name_unique UNIQUE (name),
CONSTRAINT storage_providers_provider_check CHECK (provider IN ('localfs', 's3', 'gcs'))
);
-- bot_storage_bindings: per-bot storage backend selection
CREATE TABLE IF NOT EXISTS bot_storage_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
storage_provider_id UUID NOT NULL REFERENCES storage_providers(id) ON DELETE CASCADE,
base_path TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT bot_storage_bindings_unique UNIQUE (bot_id)
);
CREATE INDEX IF NOT EXISTS idx_bot_storage_bindings_bot_id ON bot_storage_bindings(bot_id);
-- bot_history_message_assets: soft link (message -> content_hash only).
-- MIME, size, storage_key are derived from storage at read time.
CREATE TABLE IF NOT EXISTS bot_history_message_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES bot_history_messages(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'attachment',
ordinal INTEGER NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT message_asset_content_unique UNIQUE (message_id, content_hash)
);
CREATE INDEX IF NOT EXISTS idx_message_assets_message_id ON bot_history_message_assets(message_id);
-- bot_heartbeat_logs: structured execution records for periodic heartbeat checks.
CREATE TABLE IF NOT EXISTS bot_heartbeat_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
session_id UUID REFERENCES bot_sessions(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'ok' CHECK (status IN ('ok', 'alert', 'error')),
result_text TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
usage JSONB,
model_id UUID REFERENCES models(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_heartbeat_logs_bot_started ON bot_heartbeat_logs(bot_id, started_at DESC);
-- schedule_logs: structured execution records for scheduled tasks.
CREATE TABLE IF NOT EXISTS schedule_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
schedule_id UUID NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
session_id UUID REFERENCES bot_sessions(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'ok' CHECK (status IN ('ok', 'error')),
result_text TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
usage JSONB,
model_id UUID REFERENCES models(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_schedule_logs_schedule ON schedule_logs(schedule_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_schedule_logs_bot ON schedule_logs(bot_id, started_at DESC);
-- email_providers: pluggable email service backends
CREATE TABLE IF NOT EXISTS email_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
provider TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT email_providers_name_unique UNIQUE (name)
);
-- email_oauth_tokens: stored OAuth2 tokens for Gmail email providers
CREATE TABLE IF NOT EXISTS email_oauth_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email_provider_id UUID NOT NULL UNIQUE REFERENCES email_providers(id) ON DELETE CASCADE,
email_address TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
scope TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_email_oauth_tokens_state ON email_oauth_tokens(state) WHERE state != '';
-- bot_email_bindings: per-bot email provider binding with read/write/delete permissions
CREATE TABLE IF NOT EXISTS bot_email_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
email_provider_id UUID NOT NULL REFERENCES email_providers(id) ON DELETE CASCADE,
email_address TEXT NOT NULL,
can_read BOOLEAN NOT NULL DEFAULT TRUE,
can_write BOOLEAN NOT NULL DEFAULT TRUE,
can_delete BOOLEAN NOT NULL DEFAULT FALSE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT bot_email_bindings_unique UNIQUE (bot_id, email_provider_id)
);
CREATE INDEX IF NOT EXISTS idx_bot_email_bindings_bot_id ON bot_email_bindings(bot_id);
CREATE INDEX IF NOT EXISTS idx_bot_email_bindings_provider_id ON bot_email_bindings(email_provider_id);
-- email_outbox: outbound email audit log
CREATE TABLE IF NOT EXISTS email_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES email_providers(id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
message_id TEXT NOT NULL DEFAULT '',
from_address TEXT NOT NULL DEFAULT '',
to_addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
subject TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
attachments JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
error TEXT NOT NULL DEFAULT '',
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_email_outbox_provider_id ON email_outbox(provider_id);
CREATE INDEX IF NOT EXISTS idx_email_outbox_bot_id ON email_outbox(bot_id, created_at DESC);