diff --git a/.air.toml b/.air.toml index 9bd18b43..3580ea35 100644 --- a/.air.toml +++ b/.air.toml @@ -2,7 +2,7 @@ root = "." tmp_dir = "tmp" [build] -cmd = "go build -o ./tmp/memoh-server ./cmd/agent/main.go && sh devenv/bridge-build.sh" +cmd = "go build -o ./tmp/memoh-server ./cmd/agent && sh devenv/bridge-build.sh" bin = "./tmp/memoh-server" args_bin = ["serve"] include_ext = ["go", "toml"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 84d855db..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Release - -permissions: - id-token: write - contents: write - -on: - push: - tags: - - 'v*' - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v4 - with: - version: 9 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - registry-url: https://registry.npmjs.org/ - - - run: pnpm dlx changelogithub - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - build-binaries: - needs: release - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - goos: linux - goarch: amd64 - - goos: linux - goarch: arm64 - - goos: darwin - goarch: amd64 - - goos: darwin - goarch: arm64 - - goos: windows - goarch: amd64 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: pnpm/action-setup@v4 - with: - version: 10 - - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install JS dependencies - run: pnpm install --frozen-lockfile - - - name: Build release binary - env: - TARGET_OS: ${{ matrix.goos }} - TARGET_ARCH: ${{ matrix.goarch }} - VERSION: ${{ github.ref_name }} - COMMIT_HASH: ${{ github.sha }} - OUTPUT_DIR: dist - run: scripts/release.sh - - - name: Upload release assets - uses: softprops/action-gh-release@v2 - with: - files: | - dist/*.tar.gz - dist/*.zip - tag_name: ${{ github.ref_name }} - overwrite_files: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # # Uncomment the following lines to publish to npm on CI - # - # - run: pnpm install - # - run: pnpm publish -r --access public - # env: - # NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - # NPM_CONFIG_PROVENANCE: true diff --git a/.gitignore b/.gitignore index 3b78015d..d15e6eea 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,3 @@ data _main-ref/ .toolkit/ /scripts/vendor -Memoh diff --git a/AGENTS.md b/AGENTS.md index 7e3ffcbb..b3c355bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,12 +195,11 @@ Memoh/ | `mise run db-up` | Initialize and migrate the database | | `mise run db-down` | Drop the database | | `mise run build-embedded-assets` | Build and stage embedded web assets | -| `mise run build-unified` | Build unified memoh binary | +| `mise run build-unified` | Build the memoh CLI locally | | `mise run bridge:build` | Rebuild bridge binary in dev container | | `mise run lint` | Run all linters (Go + ESLint) | | `mise run lint:fix` | Run all linters with auto-fix | | `mise run release` | Release new version (bumpp) | -| `mise run release-binaries` | Build release archive for target (requires TARGET_OS TARGET_ARCH) | | `mise run install-socktainer` | Install socktainer (macOS container backend) | | `mise run install-workspace-toolkit` | Install workspace toolkit (bridge binary etc.) | diff --git a/cmd/memoh/serve.go b/cmd/agent/app.go similarity index 73% rename from cmd/memoh/serve.go rename to cmd/agent/app.go index fc54b734..4eb12ff8 100644 --- a/cmd/memoh/serve.go +++ b/cmd/agent/app.go @@ -15,10 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" "go.uber.org/fx" - "go.uber.org/fx/fxevent" "golang.org/x/crypto/bcrypt" "github.com/memohai/memoh/internal/accounts" @@ -26,7 +23,6 @@ import ( agentpkg "github.com/memohai/memoh/internal/agent" "github.com/memohai/memoh/internal/agent/background" agenttools "github.com/memohai/memoh/internal/agent/tools" - "github.com/memohai/memoh/internal/auth" "github.com/memohai/memoh/internal/bind" "github.com/memohai/memoh/internal/boot" "github.com/memohai/memoh/internal/bots" @@ -96,129 +92,6 @@ import ( "github.com/memohai/memoh/internal/workspace" ) -func runServe() { - fx.New( - fx.Provide( - provideConfig, - boot.ProvideRuntimeConfig, - provideLogger, - provideContainerService, - provideDBConn, - provideDBQueries, - provideWorkspaceManager, - provideMemoryLLM, - memprovider.NewService, - provideMemoryProviderRegistry, - models.NewService, - bots.NewService, - accounts.NewService, - acl.NewService, - settings.NewService, - provideProvidersService, - searchproviders.NewService, - policy.NewService, - mcp.NewConnectionService, - conversation.NewService, - identities.NewService, - bind.NewService, - event.NewHub, - provideTtsRegistry, - ttspkg.NewService, - provideTtsTempStore, - provideEmailRegistry, - emailpkg.NewService, - emailpkg.NewOutboxService, - provideEmailChatGateway, - provideEmailTrigger, - emailpkg.NewManager, - providePipeline, - provideEventStore, - provideDiscussDriver, - provideRouteService, - provideSessionService, - provideMessageService, - provideMediaService, - local.NewRouteHub, - provideChannelRegistry, - channel.NewStore, - provideChannelRouter, - provideChannelManager, - provideChannelLifecycleService, - provideAgent, - provideChatResolver, - browsercontexts.NewService, - provideScheduleTriggerer, - provideHeartbeatSessionCreator, - provideScheduleSessionCreator, - schedule.NewService, - provideHeartbeatTriggerer, - heartbeat.NewService, - compaction.NewService, - provideContainerdHandler, - provideFederationGateway, - provideToolGatewayService, - provideBackgroundManager, - provideToolProviders, - provideServerHandler(handlers.NewPingHandler), - provideServerHandler(provideMemohAuthHandler), - provideServerHandler(provideMemoryHandler), - provideServerHandler(provideMessageHandler), - provideServerHandler(provideSessionHandler), - provideServerHandler(handlers.NewSwaggerHandler), - provideServerHandler(handlers.NewProvidersHandler), - provideServerHandler(handlers.NewProviderOAuthHandler), - provideServerHandler(handlers.NewSearchProvidersHandler), - provideServerHandler(handlers.NewModelsHandler), - provideServerHandler(handlers.NewSettingsHandler), - provideServerHandler(handlers.NewACLHandler), - provideServerHandler(handlers.NewBindHandler), - provideServerHandler(handlers.NewScheduleHandler), - provideServerHandler(handlers.NewHeartbeatHandler), - provideServerHandler(handlers.NewCompactionHandler), - provideServerHandler(handlers.NewChannelHandler), - provideServerHandler(channel.NewWebhookServerHandler), - provideServerHandler(weixin.NewQRServerHandler), - provideServerHandler(provideUsersHandler), - provideServerHandler(handlers.NewMemoryProvidersHandler), - provideServerHandler(handlers.NewSpeechHandler), - provideServerHandler(handlers.NewBotTtsHandler), - provideServerHandler(handlers.NewEmailProvidersHandler), - provideServerHandler(handlers.NewEmailBindingsHandler), - provideServerHandler(handlers.NewEmailOutboxHandler), - provideServerHandler(handlers.NewEmailWebhookHandler), - provideServerHandler(provideEmailOAuthHandler), - emailpkg.NewDBOAuthTokenStore, - provideServerHandler(handlers.NewMCPHandler), - provideServerHandler(handlers.NewMCPOAuthHandler), - provideOAuthService, - provideServerHandler(handlers.NewTokenUsageHandler), - provideServerHandler(handlers.NewSessionInfoHandler), - provideServerHandler(handlers.NewBrowserContextsHandler), - provideServerHandler(provideWebHandler), - provideServerHandler(handlers.NewEmbeddedWebHandler), - provideServer, - ), - fx.Invoke( - injectToolProviders, - startRegistrySync, - startMemoryProviderBootstrap, - startSearchProviderBootstrap, - - startScheduleService, - startHeartbeatService, - startChannelManager, - startEmailManager, - startContainerReconciliation, - startBackgroundTaskCleanup, - startTtsTempStoreCleanup, - startServer, - ), - fx.WithLogger(func(logger *slog.Logger) fxevent.Logger { - return &fxevent.SlogLogger{Logger: logger.With(slog.String("component", "fx"))} - }), - ).Run() -} - func provideServerHandler(fn any) any { return fx.Annotate( fn, @@ -227,15 +100,6 @@ func provideServerHandler(fn any) any { ) } -func provideConfig() (config.Config, error) { - cfgPath := os.Getenv("CONFIG_PATH") - cfg, err := config.Load(cfgPath) - if err != nil { - return config.Config{}, fmt.Errorf("load config: %w", err) - } - return cfg, nil -} - func provideLogger(cfg config.Config) *slog.Logger { logger.Init(cfg.Log.Level, cfg.Log.Format) return logger.L @@ -246,7 +110,12 @@ func provideContainerService(lc fx.Lifecycle, log *slog.Logger, cfg config.Confi if err != nil { return nil, err } - lc.Append(fx.Hook{OnStop: func(_ context.Context) error { cleanup(); return nil }}) + lc.Append(fx.Hook{ + OnStop: func(_ context.Context) error { + cleanup() + return nil + }, + }) return svc, nil } @@ -255,25 +124,39 @@ func provideDBConn(lc fx.Lifecycle, cfg config.Config) (*pgxpool.Pool, error) { if err != nil { return nil, fmt.Errorf("db connect: %w", err) } - lc.Append(fx.Hook{OnStop: func(_ context.Context) error { conn.Close(); return nil }}) + lc.Append(fx.Hook{ + OnStop: func(_ context.Context) error { + conn.Close() + return nil + }, + }) return conn, nil } -func provideDBQueries(conn *pgxpool.Pool) *dbsqlc.Queries { return dbsqlc.New(conn) } +func provideDBQueries(conn *pgxpool.Pool) *dbsqlc.Queries { + return dbsqlc.New(conn) +} + func provideWorkspaceManager(log *slog.Logger, service ctr.Service, cfg config.Config, conn *pgxpool.Pool) *workspace.Manager { return workspace.NewManager(log, service, cfg.Workspace, cfg.Containerd.Namespace, conn) } func provideMemoryLLM(modelsService *models.Service, settingsService *settings.Service, queries *dbsqlc.Queries, log *slog.Logger) memprovider.LLM { - return &lazyLLMClient{modelsService: modelsService, settingsService: settingsService, queries: queries, timeout: 30 * time.Second, logger: log} + return &lazyLLMClient{ + modelsService: modelsService, + settingsService: settingsService, + queries: queries, + timeout: 30 * time.Second, + logger: log, + } } func provideMemoryProviderRegistry(log *slog.Logger, llm memprovider.LLM, chatService *conversation.Service, accountService *accounts.Service, manager *workspace.Manager, queries *dbsqlc.Queries, cfg config.Config) *memprovider.Registry { registry := memprovider.NewRegistry(log) - builtinRuntime := handlers.NewBuiltinMemoryRuntime(manager) + fileRuntime := handlers.NewBuiltinMemoryRuntime(manager) fileStore := storefs.New(log, manager) registry.RegisterFactory(string(memprovider.ProviderBuiltin), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) { - runtime, err := membuiltin.NewBuiltinRuntimeFromConfig(log, providerConfig, builtinRuntime, fileStore, queries, cfg) + runtime, err := membuiltin.NewBuiltinRuntimeFromConfig(log, providerConfig, fileRuntime, fileStore, queries, cfg) if err != nil { return nil, err } @@ -282,64 +165,18 @@ func provideMemoryProviderRegistry(log *slog.Logger, llm memprovider.LLM, chatSe p.ApplyProviderConfig(providerConfig) return p, nil }) - registry.RegisterFactory(string(memprovider.ProviderMem0), func(_ string, config map[string]any) (memprovider.Provider, error) { - return memmem0.NewMem0Provider(log, config, fileStore) + registry.RegisterFactory(string(memprovider.ProviderMem0), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) { + return memmem0.NewMem0Provider(log, providerConfig, fileStore) }) - registry.RegisterFactory(string(memprovider.ProviderOpenViking), func(_ string, config map[string]any) (memprovider.Provider, error) { - return memopenviking.NewOpenVikingProvider(log, config) + registry.RegisterFactory(string(memprovider.ProviderOpenViking), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) { + return memopenviking.NewOpenVikingProvider(log, providerConfig) }) - defaultProvider := membuiltin.NewBuiltinProvider(log, builtinRuntime, chatService, accountService) + defaultProvider := membuiltin.NewBuiltinProvider(log, fileRuntime, chatService, accountService) defaultProvider.SetLLM(llm) registry.Register("__builtin_default__", defaultProvider) return registry } -func startRegistrySync(lc fx.Lifecycle, log *slog.Logger, cfg config.Config, queries *dbsqlc.Queries) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - defs, err := registry.Load(log, cfg.Registry.ProvidersPath()) - if err != nil { - log.Warn("registry: failed to load provider definitions", slog.Any("error", err)) - return nil - } - if len(defs) == 0 { - return nil - } - return registry.Sync(ctx, log, queries, defs) - }, - }) -} - -func startMemoryProviderBootstrap(lc fx.Lifecycle, log *slog.Logger, mpService *memprovider.Service, registry *memprovider.Registry) { - mpService.SetRegistry(registry) - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - resp, err := mpService.EnsureDefault(ctx) - if err != nil { - log.Warn("failed to ensure default memory provider", slog.Any("error", err)) - return nil - } - if _, regErr := registry.Instantiate(resp.ID, resp.Provider, resp.Config); regErr != nil { - log.Warn("failed to instantiate default memory provider", slog.Any("error", regErr)) - } else { - log.Info("default memory provider ready", slog.String("id", resp.ID), slog.String("provider", resp.Provider)) - } - return nil - }, - }) -} - -func startSearchProviderBootstrap(lc fx.Lifecycle, log *slog.Logger, spService *searchproviders.Service) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - if err := spService.EnsureDefaults(ctx); err != nil { - log.Warn("failed to ensure default search providers", slog.Any("error", err)) - } - return nil - }, - }) -} - func providePipeline() *pipelinepkg.Pipeline { return pipelinepkg.NewPipeline(pipelinepkg.RenderParams{}) } @@ -439,37 +276,75 @@ func provideChatResolver(log *slog.Logger, a *agentpkg.Agent, modelsService *mod func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService *media.Service) *channel.Registry { registry := channel.NewRegistry() + tgAdapter := telegram.NewTelegramAdapter(log) tgAdapter.SetAssetOpener(mediaService) registry.MustRegister(tgAdapter) + discordAdapter := discord.NewDiscordAdapter(log) discordAdapter.SetAssetOpener(mediaService) registry.MustRegister(discordAdapter) + qqAdapter := qq.NewQQAdapter(log) qqAdapter.SetAssetOpener(mediaService) registry.MustRegister(qqAdapter) + matrixAdapter := matrix.NewMatrixAdapter(log) matrixAdapter.SetAssetOpener(mediaService) registry.MustRegister(matrixAdapter) + feishuAdapter := feishu.NewFeishuAdapter(log) feishuAdapter.SetAssetOpener(mediaService) registry.MustRegister(feishuAdapter) registry.MustRegister(wecom.NewWeComAdapter(log)) + dingTalkAdapter := dingtalk.NewDingTalkAdapter(log) registry.MustRegister(dingTalkAdapter) registry.MustRegister(wechatoa.NewWeChatOAAdapter(log)) + weixinAdapter := weixin.NewWeixinAdapter(log) weixinAdapter.SetAssetOpener(mediaService) registry.MustRegister(weixinAdapter) registry.MustRegister(local.NewWebAdapter(hub)) - - // Misskey registry.MustRegister(misskey.NewMisskeyAdapter(log)) return registry } -func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *local.RouteHub, routeService *route.DBService, sessionService *sessionpkg.Service, msgService *message.DBService, resolver *flow.Resolver, identityService *identities.Service, botService *bots.Service, aclService *acl.Service, policyService *policy.Service, bindService *bind.Service, mediaService *media.Service, ttsService *ttspkg.Service, settingsService *settings.Service, scheduleService *schedule.Service, mcpConnService *mcp.ConnectionService, modelsService *models.Service, providersService *providers.Service, memProvService *memprovider.Service, searchProvService *searchproviders.Service, browserCtxService *browsercontexts.Service, emailService *emailpkg.Service, emailOutboxService *emailpkg.OutboxService, heartbeatService *heartbeat.Service, queries *dbsqlc.Queries, containerdHandler *handlers.ContainerdHandler, manager *workspace.Manager, pipeline *pipelinepkg.Pipeline, eventStore *pipelinepkg.EventStore, discussDriver *pipelinepkg.DiscussDriver, rc *boot.RuntimeConfig) *inbound.ChannelInboundProcessor { +func provideChannelRouter( + log *slog.Logger, + registry *channel.Registry, + hub *local.RouteHub, + routeService *route.DBService, + sessionService *sessionpkg.Service, + msgService *message.DBService, + resolver *flow.Resolver, + identityService *identities.Service, + botService *bots.Service, + aclService *acl.Service, + policyService *policy.Service, + bindService *bind.Service, + mediaService *media.Service, + ttsService *ttspkg.Service, + settingsService *settings.Service, + scheduleService *schedule.Service, + mcpConnService *mcp.ConnectionService, + modelsService *models.Service, + providersService *providers.Service, + memProvService *memprovider.Service, + searchProvService *searchproviders.Service, + browserCtxService *browsercontexts.Service, + emailService *emailpkg.Service, + emailOutboxService *emailpkg.OutboxService, + heartbeatService *heartbeat.Service, + queries *dbsqlc.Queries, + containerdHandler *handlers.ContainerdHandler, + manager *workspace.Manager, + pipeline *pipelinepkg.Pipeline, + eventStore *pipelinepkg.EventStore, + discussDriver *pipelinepkg.DiscussDriver, + rc *boot.RuntimeConfig, +) *inbound.ChannelInboundProcessor { adapter, ok := registry.Get(qq.Type) if !ok { panic("qq adapter not registered") @@ -480,6 +355,7 @@ func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *loc } qqAdapter.SetChannelIdentityResolver(identityService) qqAdapter.SetRouteResolver(routeService) + processor := inbound.NewChannelInboundProcessor(log, registry, routeService, msgService, resolver, identityService, policyService, bindService, rc.JwtSecret, 5*time.Minute) processor.SetSessionEnsurer(&sessionEnsurerAdapter{svc: sessionService}) processor.SetPipeline(pipeline, eventStore, discussDriver) @@ -548,7 +424,7 @@ func provideOAuthService(log *slog.Logger, queries *dbsqlc.Queries, cfg config.C if strings.HasPrefix(host, ":") { host = "localhost" + host } - callbackURL := "http://" + host + "/oauth/mcp/callback" + callbackURL := "http://" + host + "/api/oauth/mcp/callback" return mcp.NewOAuthService(log, queries, callbackURL) } @@ -597,8 +473,8 @@ func provideMemoryHandler(log *slog.Logger, botService *bots.Service, accountSer return h } -func provideMemohAuthHandler(log *slog.Logger, accountService *accounts.Service, rc *boot.RuntimeConfig) *memohAuthHandler { - return &memohAuthHandler{inner: handlers.NewAuthHandler(log, accountService, rc.JwtSecret, rc.JwtExpiresIn)} +func provideAuthHandler(log *slog.Logger, accountService *accounts.Service, rc *boot.RuntimeConfig) *handlers.AuthHandler { + return handlers.NewAuthHandler(log, accountService, rc.JwtSecret, rc.JwtExpiresIn) } func provideMessageHandler(log *slog.Logger, chatService *conversation.Service, msgService *message.DBService, mediaService *media.Service, botService *bots.Service, accountService *accounts.Service, hub *event.Hub) *handlers.MessageHandler { @@ -611,13 +487,6 @@ func provideSessionHandler(log *slog.Logger, sessionService *sessionpkg.Service, return handlers.NewSessionHandler(log, sessionService, botService, accountService) } -type memohAuthHandler struct{ inner *handlers.AuthHandler } - -func (h *memohAuthHandler) Register(e *echo.Echo) { - e.POST("/api/auth/login", h.inner.Login) - e.POST("/api/auth/refresh", h.inner.Refresh) -} - func provideMediaService(log *slog.Logger, manager *workspace.Manager, cfg config.Config) *media.Service { primary := containerfs.New(manager) dataRoot := cfg.Workspace.DataRoot @@ -641,222 +510,6 @@ func provideWebHandler(channelManager *channel.Manager, channelStore *channel.St return h } -type serverParams struct { - fx.In - Logger *slog.Logger - RuntimeConfig *boot.RuntimeConfig - Config config.Config - ServerHandlers []server.Handler `group:"server_handlers"` - ContainerdHandler *handlers.ContainerdHandler -} - -type memohServer struct { - echo *echo.Echo - addr string -} - -var ( - memohJWTExactSkipPaths = map[string]struct{}{ - "/": {}, - "/ping": {}, - "/health": {}, - "/api/swagger.json": {}, - "/api/auth/login": {}, - "/logo.png": {}, - "/channels/telegram.webp": {}, - "/channels/feishu.png": {}, - } - memohJWTPrefixSkipPaths = []string{ - "/assets/", - "/api/docs", - "/channels/feishu/webhook/", - "/email/mailgun/webhook/", - "/email/oauth/callback", - } - memohSPABackendPrefixes = []string{ - "/api", - "/auth", - "/channels", - "/containers", - "/users", - "/bots", - "/models", - "/providers", - "/search_providers", - "/email-providers", - "/email", - "/settings", - "/memory", - "/message", - "/mcp", - "/schedule", - "/bind", - "/preauth", - "/ping", - "/health", - } - memohAPIRewriteBypassExact = map[string]struct{}{ - "/api/swagger.json": {}, - } - memohAPIRewriteBypassPrefixes = []string{ - "/api/docs", - "/api/auth/", - } -) - -func (s *memohServer) Start() error { return s.echo.Start(s.addr) } -func (s *memohServer) Stop(ctx context.Context) error { return s.echo.Shutdown(ctx) } - -func provideServer(params serverParams) *memohServer { - allHandlers := make([]server.Handler, 0, len(params.ServerHandlers)+1) - allHandlers = append(allHandlers, params.ServerHandlers...) - allHandlers = append(allHandlers, params.ContainerdHandler) - - addr := params.RuntimeConfig.ServerAddr - if addr == "" { - addr = ":8080" - } - e := echo.New() - e.HideBanner = true - e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - rewriteAPIPathForMemoh(c.Request()) - return next(c) - } - }) - e.Use(middleware.Recover()) - e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ - LogStatus: true, - LogURI: true, - LogMethod: true, - LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { - params.Logger.Info("request", - slog.String("method", v.Method), - slog.String("uri", v.URI), - slog.Int("status", v.Status), - slog.Duration("latency", v.Latency), - slog.String("remote_ip", c.RealIP()), - ) - return nil - }, - })) - e.Use(auth.JWTMiddleware(params.Config.Auth.JWTSecret, func(c echo.Context) bool { - return shouldSkipJWTForMemoh(c.Request().URL.Path) - })) - for _, h := range allHandlers { - if h != nil { - h.Register(e) - } - } - return &memohServer{echo: e, addr: addr} -} - -func startScheduleService(lc fx.Lifecycle, scheduleService *schedule.Service) { - lc.Append(fx.Hook{OnStart: func(ctx context.Context) error { return scheduleService.Bootstrap(ctx) }}) -} - -func startHeartbeatService(lc fx.Lifecycle, heartbeatService *heartbeat.Service) { - lc.Append(fx.Hook{OnStart: func(ctx context.Context) error { return heartbeatService.Bootstrap(ctx) }}) -} - -func startChannelManager(lc fx.Lifecycle, channelManager *channel.Manager) { - ctx, cancel := context.WithCancel(context.Background()) - lc.Append(fx.Hook{ - OnStart: func(_ context.Context) error { channelManager.Start(ctx); return nil }, - OnStop: func(stopCtx context.Context) error { cancel(); return channelManager.Shutdown(stopCtx) }, - }) -} - -func startContainerReconciliation(lc fx.Lifecycle, manager *workspace.Manager, _ *handlers.ContainerdHandler, _ *mcp.ToolGatewayService) { - lc.Append(fx.Hook{OnStart: func(ctx context.Context) error { go manager.ReconcileContainers(ctx); return nil }}) -} - -func startServer(lc fx.Lifecycle, logger *slog.Logger, srv *memohServer, shutdowner fx.Shutdowner, cfg config.Config, queries *dbsqlc.Queries, botService *bots.Service, _ *handlers.ContainerdHandler, manager *workspace.Manager, mcpConnService *mcp.ConnectionService, toolGateway *mcp.ToolGatewayService, channelManager *channel.Manager, modelsService *models.Service) { - fmt.Printf("Starting Memoh Agent %s\n", version.GetInfo()) - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - if err := ensureAdminUser(ctx, logger, queries, cfg); err != nil { - return err - } - botService.SetContainerLifecycle(manager) - botService.SetContainerReachability(func(ctx context.Context, botID string) error { - _, err := manager.MCPClient(ctx, botID) - return err - }) - botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter(mcpchecker.NewChecker(logger, mcpConnService, toolGateway))) - botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter(channelchecker.NewChecker(logger, channelManager))) - botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter(modelchecker.NewChecker(logger, modelchecker.NewQueriesLookup(queries), modelsService))) - go func() { - if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Error("server failed", slog.Any("error", err)) - _ = shutdowner.Shutdown() - } - }() - return nil - }, - OnStop: func(ctx context.Context) error { - if err := srv.Stop(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("server stop: %w", err) - } - return nil - }, - }) -} - -func shouldSkipJWTForMemoh(path string) bool { - if _, ok := memohJWTExactSkipPaths[path]; ok { - return true - } - if hasAnyPrefix(path, memohJWTPrefixSkipPaths) { - return true - } - // Treat non-backend, extension-less paths as SPA routes (e.g. /chat, /settings/profile). - return shouldServeSPARouteForMemoh(path) -} - -func shouldServeSPARouteForMemoh(path string) bool { - if path == "" || path == "/" { - return true - } - if strings.Contains(path, ".") { - return false - } - if hasAnyPrefix(path, memohSPABackendPrefixes) { - return false - } - return true -} - -func rewriteAPIPathForMemoh(r *http.Request) { - if r == nil || r.URL == nil { - return - } - path := r.URL.Path - if !strings.HasPrefix(path, "/api/") { - return - } - if _, ok := memohAPIRewriteBypassExact[path]; ok { - return - } - if hasAnyPrefix(path, memohAPIRewriteBypassPrefixes) { - return - } - rewritten := strings.TrimPrefix(path, "/api") - if rewritten == "" { - rewritten = "/" - } - r.URL.Path = rewritten -} - -func hasAnyPrefix(path string, prefixes []string) bool { - for _, prefix := range prefixes { - if strings.HasPrefix(path, prefix) { - return true - } - } - return false -} - func provideTtsRegistry(log *slog.Logger) *ttspkg.Registry { reg := ttspkg.NewRegistry() reg.Register(ttsedge.NewEdgeAdapter(log)) @@ -895,8 +548,6 @@ func startBackgroundTaskCleanup(lc fx.Lifecycle, mgr *background.Manager) { }) } -// settingsTtsModelResolver adapts settings.Service to the ttsModelResolver interface -// expected by ChannelInboundProcessor and LocalChannelHandler. type sessionEnsurerAdapter struct { svc *sessionpkg.Service } @@ -945,8 +596,7 @@ func provideEmailRegistry(log *slog.Logger, tokenStore *emailpkg.DBOAuthTokenSto return reg } -func provideProvidersService(log *slog.Logger, queries *dbsqlc.Queries, cfg config.Config) *providers.Service { - _ = cfg +func provideProvidersService(log *slog.Logger, queries *dbsqlc.Queries, _ config.Config) *providers.Service { return providers.NewService(log, queries, defaultProviderOAuthCallbackURL()) } @@ -986,7 +636,162 @@ func startEmailManager(lc fx.Lifecycle, emailManager *emailpkg.Manager) { }() return nil }, - OnStop: func(stopCtx context.Context) error { cancel(); emailManager.Stop(stopCtx); return nil }, + OnStop: func(stopCtx context.Context) error { + cancel() + emailManager.Stop(stopCtx) + return nil + }, + }) +} + +type serverParams struct { + fx.In + + Logger *slog.Logger + RuntimeConfig *boot.RuntimeConfig + Config config.Config + ServerHandlers []server.Handler `group:"server_handlers"` + ContainerdHandler *handlers.ContainerdHandler +} + +func provideServer(params serverParams) *server.Server { + allHandlers := make([]server.Handler, 0, len(params.ServerHandlers)+1) + allHandlers = append(allHandlers, params.ServerHandlers...) + allHandlers = append(allHandlers, params.ContainerdHandler) + return server.NewServer(params.Logger, params.RuntimeConfig.ServerAddr, params.Config.Auth.JWTSecret, allHandlers...) +} + +func startRegistrySync(lc fx.Lifecycle, log *slog.Logger, cfg config.Config, queries *dbsqlc.Queries) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + defs, err := registry.Load(log, cfg.Registry.ProvidersPath()) + if err != nil { + log.Warn("registry: failed to load provider definitions", slog.Any("error", err)) + return nil + } + if len(defs) == 0 { + return nil + } + return registry.Sync(ctx, log, queries, defs) + }, + }) +} + +func startMemoryProviderBootstrap(lc fx.Lifecycle, log *slog.Logger, mpService *memprovider.Service, registry *memprovider.Registry) { + mpService.SetRegistry(registry) + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + resp, err := mpService.EnsureDefault(ctx) + if err != nil { + log.Warn("failed to ensure default memory provider", slog.Any("error", err)) + return nil + } + if _, regErr := registry.Instantiate(resp.ID, resp.Provider, resp.Config); regErr != nil { + log.Warn("failed to instantiate default memory provider", slog.Any("error", regErr)) + } else { + log.Info("default memory provider ready", slog.String("id", resp.ID), slog.String("provider", resp.Provider)) + } + return nil + }, + }) +} + +func startSearchProviderBootstrap(lc fx.Lifecycle, log *slog.Logger, spService *searchproviders.Service) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + if err := spService.EnsureDefaults(ctx); err != nil { + log.Warn("failed to ensure default search providers", slog.Any("error", err)) + } + return nil + }, + }) +} + +func startScheduleService(lc fx.Lifecycle, scheduleService *schedule.Service) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return scheduleService.Bootstrap(ctx) + }, + }) +} + +func startHeartbeatService(lc fx.Lifecycle, heartbeatService *heartbeat.Service) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return heartbeatService.Bootstrap(ctx) + }, + }) +} + +func wireResolverOutbound(resolver *flow.Resolver, channelManager *channel.Manager) { + resolver.SetOutboundFn(func(ctx context.Context, botID, channelType, target, text string) error { + return channelManager.Send(ctx, botID, channel.ChannelType(channelType), channel.SendRequest{ + Target: target, + Message: channel.Message{Text: text}, + }) + }) +} + +func startChannelManager(lc fx.Lifecycle, channelManager *channel.Manager) { + ctx, cancel := context.WithCancel(context.Background()) + lc.Append(fx.Hook{ + OnStart: func(_ context.Context) error { + channelManager.Start(ctx) + return nil + }, + OnStop: func(stopCtx context.Context) error { + cancel() + return channelManager.Shutdown(stopCtx) + }, + }) +} + +func startContainerReconciliation(lc fx.Lifecycle, manager *workspace.Manager, _ *handlers.ContainerdHandler, _ *mcp.ToolGatewayService) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + go manager.ReconcileContainers(ctx) + return nil + }, + }) +} + +func startServer(lc fx.Lifecycle, logger *slog.Logger, srv *server.Server, shutdowner fx.Shutdowner, cfg config.Config, queries *dbsqlc.Queries, botService *bots.Service, _ *handlers.ContainerdHandler, manager *workspace.Manager, mcpConnService *mcp.ConnectionService, toolGateway *mcp.ToolGatewayService, channelManager *channel.Manager, modelsService *models.Service) { + fmt.Printf("Starting Memoh Agent %s\n", version.GetInfo()) + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + if err := ensureAdminUser(ctx, logger, queries, cfg); err != nil { + return err + } + botService.SetContainerLifecycle(manager) + botService.SetContainerReachability(func(ctx context.Context, botID string) error { + _, err := manager.MCPClient(ctx, botID) + return err + }) + botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter( + mcpchecker.NewChecker(logger, mcpConnService, toolGateway), + )) + botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter( + channelchecker.NewChecker(logger, channelManager), + )) + botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter( + modelchecker.NewChecker(logger, modelchecker.NewQueriesLookup(queries), modelsService), + )) + + go func() { + if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("server failed", slog.Any("error", err)) + _ = shutdowner.Shutdown() + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + if err := srv.Stop(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("server stop: %w", err) + } + return nil + }, }) } @@ -1001,6 +806,7 @@ func ensureAdminUser(ctx context.Context, log *slog.Logger, queries *dbsqlc.Quer if count > 0 { return nil } + username := strings.TrimSpace(cfg.Admin.Username) password := strings.TrimSpace(cfg.Admin.Password) email := strings.TrimSpace(cfg.Admin.Email) @@ -1010,24 +816,37 @@ func ensureAdminUser(ctx context.Context, log *slog.Logger, queries *dbsqlc.Quer if password == "change-your-password-here" { log.Warn("admin password uses default placeholder; please update config.toml") } + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } - user, err := queries.CreateUser(ctx, dbsqlc.CreateUserParams{IsActive: true, Metadata: []byte("{}")}) + + user, err := queries.CreateUser(ctx, dbsqlc.CreateUserParams{ + IsActive: true, + Metadata: []byte("{}"), + }) if err != nil { return fmt.Errorf("create admin user: %w", err) } + emailValue := pgtype.Text{Valid: false} if email != "" { emailValue = pgtype.Text{String: email, Valid: true} } displayName := pgtype.Text{String: username, Valid: true} dataRoot := pgtype.Text{String: cfg.Workspace.DataRoot, Valid: cfg.Workspace.DataRoot != ""} + _, err = queries.CreateAccount(ctx, dbsqlc.CreateAccountParams{ - UserID: user.ID, Username: pgtype.Text{String: username, Valid: true}, Email: emailValue, - PasswordHash: pgtype.Text{String: string(hashed), Valid: true}, Role: "admin", - DisplayName: displayName, AvatarUrl: pgtype.Text{Valid: false}, IsActive: true, DataRoot: dataRoot, + UserID: user.ID, + Username: pgtype.Text{String: username, Valid: true}, + Email: emailValue, + PasswordHash: pgtype.Text{String: string(hashed), Valid: true}, + Role: "admin", + DisplayName: displayName, + AvatarUrl: pgtype.Text{Valid: false}, + IsActive: true, + DataRoot: dataRoot, }) if err != nil { return err @@ -1081,11 +900,9 @@ func (c *lazyLLMClient) resolve(ctx context.Context, botID string) (memprovider. return nil, errors.New("models service not configured") } - // Try to use the bot's configured chat model for memory operations. chatModelID := "" if c.settingsService != nil && strings.TrimSpace(botID) != "" { if botSettings, err := c.settingsService.GetBot(ctx, botID); err == nil { - // Prefer compaction model (smaller/cheaper), then chat model. if id := strings.TrimSpace(botSettings.CompactionModelID); id != "" { chatModelID = id } else if id := strings.TrimSpace(botSettings.ChatModelID); id != "" { @@ -1107,7 +924,9 @@ func (c *lazyLLMClient) resolve(ctx context.Context, botID string) (memprovider. }), nil } -type skillLoaderAdapter struct{ handler *handlers.ContainerdHandler } +type skillLoaderAdapter struct { + handler *handlers.ContainerdHandler +} func (a *skillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]flow.SkillEntry, error) { items, err := a.handler.LoadSkills(ctx, botID) @@ -1116,12 +935,19 @@ func (a *skillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]fl } entries := make([]flow.SkillEntry, len(items)) for i, item := range items { - entries[i] = flow.SkillEntry{Name: item.Name, Description: item.Description, Content: item.Content, Metadata: item.Metadata} + entries[i] = flow.SkillEntry{ + Name: item.Name, + Description: item.Description, + Content: item.Content, + Metadata: item.Metadata, + } } return entries, nil } -type mediaAssetResolverAdapter struct{ media *media.Service } +type mediaAssetResolverAdapter struct { + media *media.Service +} func (a *mediaAssetResolverAdapter) Stat(ctx context.Context, botID, contentHash string) (media.Asset, error) { if a == nil || a.media == nil { @@ -1165,7 +991,9 @@ func (a *mediaAssetResolverAdapter) IngestContainerFile(ctx context.Context, bot return a.media.IngestContainerFile(ctx, botID, containerPath) } -type gatewayAssetLoaderAdapter struct{ media *media.Service } +type gatewayAssetLoaderAdapter struct { + media *media.Service +} func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, contentHash string) (io.ReadCloser, string, error) { if a == nil || a.media == nil { @@ -1178,7 +1006,6 @@ func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, c return reader, strings.TrimSpace(asset.Mime), nil } -// commandSkillLoaderAdapter bridges handlers.ContainerdHandler to command.SkillLoader. type commandSkillLoaderAdapter struct { handler *handlers.ContainerdHandler } @@ -1195,7 +1022,6 @@ func (a *commandSkillLoaderAdapter) LoadSkills(ctx context.Context, botID string return skills, nil } -// commandContainerFSAdapter bridges workspace.Manager to command.ContainerFS. type commandContainerFSAdapter struct { manager *workspace.Manager } diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 9f2c4132..ee49acf6 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -1,108 +1,10 @@ package main import ( - "context" - "errors" "fmt" - "io" - "io/fs" - "log/slog" - "net/http" "os" - stdpath "path" - "path/filepath" - "strings" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - "go.uber.org/fx" - "go.uber.org/fx/fxevent" - "golang.org/x/crypto/bcrypt" - - dbembed "github.com/memohai/memoh/db" - "github.com/memohai/memoh/internal/accounts" - "github.com/memohai/memoh/internal/acl" - agentpkg "github.com/memohai/memoh/internal/agent" - "github.com/memohai/memoh/internal/agent/background" - agenttools "github.com/memohai/memoh/internal/agent/tools" - "github.com/memohai/memoh/internal/bind" - "github.com/memohai/memoh/internal/boot" - "github.com/memohai/memoh/internal/bots" - "github.com/memohai/memoh/internal/browsercontexts" - "github.com/memohai/memoh/internal/channel" - "github.com/memohai/memoh/internal/channel/adapters/dingtalk" - "github.com/memohai/memoh/internal/channel/adapters/discord" - "github.com/memohai/memoh/internal/channel/adapters/feishu" - "github.com/memohai/memoh/internal/channel/adapters/local" - "github.com/memohai/memoh/internal/channel/adapters/matrix" - "github.com/memohai/memoh/internal/channel/adapters/misskey" - "github.com/memohai/memoh/internal/channel/adapters/qq" - "github.com/memohai/memoh/internal/channel/adapters/telegram" - "github.com/memohai/memoh/internal/channel/adapters/wechatoa" - "github.com/memohai/memoh/internal/channel/adapters/wecom" - "github.com/memohai/memoh/internal/channel/adapters/weixin" - "github.com/memohai/memoh/internal/channel/identities" - "github.com/memohai/memoh/internal/channel/inbound" - "github.com/memohai/memoh/internal/channel/route" - "github.com/memohai/memoh/internal/command" - "github.com/memohai/memoh/internal/compaction" - "github.com/memohai/memoh/internal/config" - ctr "github.com/memohai/memoh/internal/containerd" - "github.com/memohai/memoh/internal/conversation" - "github.com/memohai/memoh/internal/conversation/flow" - "github.com/memohai/memoh/internal/db" - dbsqlc "github.com/memohai/memoh/internal/db/sqlc" - emailpkg "github.com/memohai/memoh/internal/email" - emailgeneric "github.com/memohai/memoh/internal/email/adapters/generic" - emailgmail "github.com/memohai/memoh/internal/email/adapters/gmail" - emailmailgun "github.com/memohai/memoh/internal/email/adapters/mailgun" - "github.com/memohai/memoh/internal/handlers" - "github.com/memohai/memoh/internal/healthcheck" - channelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/channel" - mcpchecker "github.com/memohai/memoh/internal/healthcheck/checkers/mcp" - modelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/model" - "github.com/memohai/memoh/internal/heartbeat" - "github.com/memohai/memoh/internal/logger" - "github.com/memohai/memoh/internal/mcp" - mcpfederation "github.com/memohai/memoh/internal/mcp/sources/federation" - "github.com/memohai/memoh/internal/media" - memprovider "github.com/memohai/memoh/internal/memory/adapters" - membuiltin "github.com/memohai/memoh/internal/memory/adapters/builtin" - memmem0 "github.com/memohai/memoh/internal/memory/adapters/mem0" - memopenviking "github.com/memohai/memoh/internal/memory/adapters/openviking" - "github.com/memohai/memoh/internal/memory/memllm" - storefs "github.com/memohai/memoh/internal/memory/storefs" - "github.com/memohai/memoh/internal/message" - "github.com/memohai/memoh/internal/message/event" - "github.com/memohai/memoh/internal/messaging" - "github.com/memohai/memoh/internal/models" - pipelinepkg "github.com/memohai/memoh/internal/pipeline" - "github.com/memohai/memoh/internal/policy" - "github.com/memohai/memoh/internal/providers" - "github.com/memohai/memoh/internal/registry" - "github.com/memohai/memoh/internal/schedule" - "github.com/memohai/memoh/internal/searchproviders" - "github.com/memohai/memoh/internal/server" - sessionpkg "github.com/memohai/memoh/internal/session" - "github.com/memohai/memoh/internal/settings" - "github.com/memohai/memoh/internal/storage/providers/containerfs" - "github.com/memohai/memoh/internal/storage/providers/fallback" - "github.com/memohai/memoh/internal/storage/providers/localfs" - ttspkg "github.com/memohai/memoh/internal/tts" - ttsedge "github.com/memohai/memoh/internal/tts/adapter/edge" - "github.com/memohai/memoh/internal/version" - "github.com/memohai/memoh/internal/workspace" ) -func migrationsFS() fs.FS { - sub, err := fs.Sub(dbembed.MigrationsFS, "migrations") - if err != nil { - panic(fmt.Sprintf("embedded migrations: %v", err)) - } - return sub -} - func main() { cmd := "serve" if len(os.Args) > 1 { @@ -115,7 +17,9 @@ func main() { case "migrate": runMigrate(os.Args[2:]) case "version": - fmt.Printf("memoh-server %s\n", version.GetInfo()) + if err := runVersion(); err != nil { + os.Exit(1) + } default: fmt.Fprintf(os.Stderr, "Usage: memoh-server \n\nCommands:\n serve Start the server (default)\n migrate Run database migrations (up|down|version|force)\n version Print version information\n") os.Exit(1) @@ -127,1207 +31,8 @@ func runMigrate(args []string) { fmt.Fprintf(os.Stderr, "Usage: memoh-server migrate \n") os.Exit(1) } - - cfg, err := provideConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "config: %v\n", err) - os.Exit(1) - } - - logger.Init(cfg.Log.Level, cfg.Log.Format) - log := logger.L - - migrateCmd := args[0] - var migrateArgs []string - if len(args) > 1 { - migrateArgs = args[1:] - } - - if err := db.RunMigrate(log, cfg.Postgres, migrationsFS(), migrateCmd, migrateArgs); err != nil { - log.Error("migration failed", slog.Any("error", err)) + if err := runMigrateCommand(args); err != nil { + fmt.Fprintf(os.Stderr, "migrate: %v\n", err) os.Exit(1) } } - -func runServe() { - fx.New( - fx.Provide( - provideConfig, - boot.ProvideRuntimeConfig, - provideLogger, - provideContainerService, - provideDBConn, - provideDBQueries, - - // container & workspace infrastructure - provideWorkspaceManager, - - // memory pipeline - provideMemoryLLM, - memprovider.NewService, - provideMemoryProviderRegistry, - - // domain services (auto-wired) - models.NewService, - bots.NewService, - accounts.NewService, - acl.NewService, - settings.NewService, - provideProvidersService, - searchproviders.NewService, - browsercontexts.NewService, - policy.NewService, - mcp.NewConnectionService, - conversation.NewService, - identities.NewService, - bind.NewService, - event.NewHub, - - // tts infrastructure - provideTtsRegistry, - ttspkg.NewService, - provideTtsTempStore, - - // email infrastructure - emailpkg.NewDBOAuthTokenStore, - provideEmailRegistry, - emailpkg.NewService, - emailpkg.NewOutboxService, - provideEmailChatGateway, - provideEmailTrigger, - emailpkg.NewManager, - - // services requiring provide functions - provideRouteService, - provideSessionService, - provideMessageService, - provideMediaService, - - // DCP pipeline - providePipeline, - provideEventStore, - provideDiscussDriver, - - // channel infrastructure - local.NewRouteHub, - provideChannelRegistry, - channel.NewStore, - provideChannelRouter, - provideChannelManager, - provideChannelLifecycleService, - - // agent & conversation flow - provideAgent, - provideChatResolver, - provideScheduleTriggerer, - provideHeartbeatSessionCreator, - provideScheduleSessionCreator, - schedule.NewService, - provideHeartbeatTriggerer, - heartbeat.NewService, - compaction.NewService, - - // containerd handler & tool gateway - provideContainerdHandler, - provideFederationGateway, - provideToolGatewayService, - provideBackgroundManager, - provideToolProviders, - - // http handlers (group:"server_handlers") - provideServerHandler(handlers.NewPingHandler), - provideServerHandler(provideAuthHandler), - provideServerHandler(provideMemoryHandler), - provideServerHandler(provideMessageHandler), - provideServerHandler(provideSessionHandler), - provideServerHandler(handlers.NewSwaggerHandler), - provideServerHandler(handlers.NewProvidersHandler), - provideServerHandler(handlers.NewProviderOAuthHandler), - provideServerHandler(handlers.NewSearchProvidersHandler), - provideServerHandler(handlers.NewModelsHandler), - provideServerHandler(handlers.NewSettingsHandler), - provideServerHandler(handlers.NewACLHandler), - provideServerHandler(handlers.NewBindHandler), - provideServerHandler(handlers.NewScheduleHandler), - provideServerHandler(handlers.NewHeartbeatHandler), - provideServerHandler(handlers.NewCompactionHandler), - provideServerHandler(handlers.NewChannelHandler), - provideServerHandler(channel.NewWebhookServerHandler), - provideServerHandler(weixin.NewQRServerHandler), - provideServerHandler(provideUsersHandler), - provideServerHandler(handlers.NewMemoryProvidersHandler), - provideServerHandler(handlers.NewSpeechHandler), - provideServerHandler(handlers.NewBotTtsHandler), - provideServerHandler(handlers.NewEmailProvidersHandler), - provideServerHandler(handlers.NewEmailBindingsHandler), - provideServerHandler(handlers.NewEmailOutboxHandler), - provideServerHandler(handlers.NewEmailWebhookHandler), - provideServerHandler(provideEmailOAuthHandler), - provideServerHandler(handlers.NewMCPHandler), - provideServerHandler(handlers.NewMCPOAuthHandler), - provideOAuthService, - provideServerHandler(handlers.NewTokenUsageHandler), - provideServerHandler(handlers.NewSessionInfoHandler), - provideServerHandler(handlers.NewBrowserContextsHandler), - provideServerHandler(handlers.NewSupermarketHandler), - provideServerHandler(provideWebHandler), - - provideServer, - ), - fx.Invoke( - injectToolProviders, - startRegistrySync, - startMemoryProviderBootstrap, - startSearchProviderBootstrap, - - startScheduleService, - startHeartbeatService, - wireResolverOutbound, - startChannelManager, - startEmailManager, - startContainerReconciliation, - startBackgroundTaskCleanup, - startTtsTempStoreCleanup, - startServer, - ), - fx.WithLogger(func(logger *slog.Logger) fxevent.Logger { - return &fxevent.SlogLogger{Logger: logger.With(slog.String("component", "fx"))} - }), - ).Run() -} - -// --------------------------------------------------------------------------- -// fx helper -// --------------------------------------------------------------------------- - -func provideServerHandler(fn any) any { - return fx.Annotate( - fn, - fx.As(new(server.Handler)), - fx.ResultTags(`group:"server_handlers"`), - ) -} - -// --------------------------------------------------------------------------- -// infrastructure providers -// --------------------------------------------------------------------------- - -func provideConfig() (config.Config, error) { - cfgPath := os.Getenv("CONFIG_PATH") - cfg, err := config.Load(cfgPath) - if err != nil { - return config.Config{}, fmt.Errorf("load config: %w", err) - } - return cfg, nil -} - -func provideLogger(cfg config.Config) *slog.Logger { - logger.Init(cfg.Log.Level, cfg.Log.Format) - return logger.L -} - -func provideContainerService(lc fx.Lifecycle, log *slog.Logger, cfg config.Config, rc *boot.RuntimeConfig) (ctr.Service, error) { - svc, cleanup, err := ctr.ProvideService(context.Background(), log, cfg, rc.ContainerBackend) - if err != nil { - return nil, err - } - lc.Append(fx.Hook{ - OnStop: func(_ context.Context) error { - cleanup() - return nil - }, - }) - return svc, nil -} - -func provideDBConn(lc fx.Lifecycle, cfg config.Config) (*pgxpool.Pool, error) { - conn, err := db.Open(context.Background(), cfg.Postgres) - if err != nil { - return nil, fmt.Errorf("db connect: %w", err) - } - lc.Append(fx.Hook{ - OnStop: func(_ context.Context) error { - conn.Close() - return nil - }, - }) - return conn, nil -} - -func provideDBQueries(conn *pgxpool.Pool) *dbsqlc.Queries { - return dbsqlc.New(conn) -} - -func provideWorkspaceManager(log *slog.Logger, service ctr.Service, cfg config.Config, conn *pgxpool.Pool) *workspace.Manager { - return workspace.NewManager(log, service, cfg.Workspace, cfg.Containerd.Namespace, conn) -} - -// --------------------------------------------------------------------------- -// memory providers -// --------------------------------------------------------------------------- - -func provideMemoryLLM(modelsService *models.Service, settingsService *settings.Service, queries *dbsqlc.Queries, log *slog.Logger) memprovider.LLM { - return &lazyLLMClient{ - modelsService: modelsService, - settingsService: settingsService, - queries: queries, - timeout: 30 * time.Second, - logger: log, - } -} - -func provideMemoryProviderRegistry(log *slog.Logger, llm memprovider.LLM, chatService *conversation.Service, accountService *accounts.Service, manager *workspace.Manager, queries *dbsqlc.Queries, cfg config.Config) *memprovider.Registry { - registry := memprovider.NewRegistry(log) - fileRuntime := handlers.NewBuiltinMemoryRuntime(manager) - fileStore := storefs.New(log, manager) - registry.RegisterFactory(string(memprovider.ProviderBuiltin), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) { - runtime, err := membuiltin.NewBuiltinRuntimeFromConfig(log, providerConfig, fileRuntime, fileStore, queries, cfg) - if err != nil { - return nil, err - } - p := membuiltin.NewBuiltinProvider(log, runtime, chatService, accountService) - p.SetLLM(llm) - p.ApplyProviderConfig(providerConfig) - return p, nil - }) - registry.RegisterFactory(string(memprovider.ProviderMem0), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) { - return memmem0.NewMem0Provider(log, providerConfig, fileStore) - }) - registry.RegisterFactory(string(memprovider.ProviderOpenViking), func(_ string, providerConfig map[string]any) (memprovider.Provider, error) { - return memopenviking.NewOpenVikingProvider(log, providerConfig) - }) - defaultProvider := membuiltin.NewBuiltinProvider(log, fileRuntime, chatService, accountService) - defaultProvider.SetLLM(llm) - registry.Register("__builtin_default__", defaultProvider) - return registry -} - -// --------------------------------------------------------------------------- -// domain service providers (interface adapters) -// --------------------------------------------------------------------------- - -func providePipeline() *pipelinepkg.Pipeline { - return pipelinepkg.NewPipeline(pipelinepkg.RenderParams{}) -} - -func provideEventStore(log *slog.Logger, queries *dbsqlc.Queries) *pipelinepkg.EventStore { - return pipelinepkg.NewEventStore(log, queries) -} - -func provideDiscussDriver(log *slog.Logger, pipeline *pipelinepkg.Pipeline, eventStore *pipelinepkg.EventStore, agent *agentpkg.Agent, msgService *message.DBService) *pipelinepkg.DiscussDriver { - return pipelinepkg.NewDiscussDriver(pipelinepkg.DiscussDriverDeps{ - Pipeline: pipeline, - EventStore: eventStore, - Agent: agent, - MessageService: msgService, - Logger: log, - }) -} - -func provideRouteService(log *slog.Logger, queries *dbsqlc.Queries, chatService *conversation.Service) *route.DBService { - return route.NewService(log, queries, chatService) -} - -func provideSessionService(log *slog.Logger, queries *dbsqlc.Queries) *sessionpkg.Service { - return sessionpkg.NewService(log, queries) -} - -func provideMessageService(log *slog.Logger, queries *dbsqlc.Queries, hub *event.Hub) *message.DBService { - return message.NewService(log, queries, hub) -} - -func provideScheduleTriggerer(resolver *flow.Resolver) schedule.Triggerer { - return flow.NewScheduleGateway(resolver) -} - -func provideHeartbeatTriggerer(resolver *flow.Resolver) heartbeat.Triggerer { - return flow.NewHeartbeatGateway(resolver) -} - -type sessionCreatorAdapter struct { - svc *sessionpkg.Service -} - -func (a *sessionCreatorAdapter) CreateSession(ctx context.Context, botID, sessionType string) (string, error) { - sess, err := a.svc.Create(ctx, sessionpkg.CreateInput{ - BotID: botID, - Type: sessionType, - }) - if err != nil { - return "", err - } - return sess.ID, nil -} - -func provideHeartbeatSessionCreator(sessionService *sessionpkg.Service) heartbeat.SessionCreator { - return &sessionCreatorAdapter{svc: sessionService} -} - -func provideScheduleSessionCreator(sessionService *sessionpkg.Service) schedule.SessionCreator { - return &sessionCreatorAdapter{svc: sessionService} -} - -// --------------------------------------------------------------------------- -// conversation flow -// --------------------------------------------------------------------------- - -func provideAgent(log *slog.Logger, manager *workspace.Manager) *agentpkg.Agent { - return agentpkg.New(agentpkg.Deps{ - BridgeProvider: manager, - Logger: log, - }) -} - -func injectToolProviders(a *agentpkg.Agent, msgService *message.DBService, providers []agenttools.ToolProvider) { - a.SetToolProviders(providers) - for _, p := range providers { - if sp, ok := p.(*agenttools.SpawnProvider); ok { - sp.SetAgent(agentpkg.NewSpawnAdapter(a)) - sp.SetMessageService(msgService) - sp.SetSystemPromptFunc(agentpkg.SpawnSystemPrompt) - sp.SetModelCreator(agentpkg.SpawnModelCreatorFunc()) - } - } -} - -func provideChatResolver(log *slog.Logger, a *agentpkg.Agent, modelsService *models.Service, queries *dbsqlc.Queries, chatService *conversation.Service, msgService *message.DBService, settingsService *settings.Service, accountService *accounts.Service, mediaService *media.Service, containerdHandler *handlers.ContainerdHandler, memoryRegistry *memprovider.Registry, routeService *route.DBService, sessionService *sessionpkg.Service, eventHub *event.Hub, compactionService *compaction.Service, pipeline *pipelinepkg.Pipeline, rc *boot.RuntimeConfig, bgManager *background.Manager) *flow.Resolver { - resolver := flow.NewResolver(log, modelsService, queries, chatService, msgService, settingsService, accountService, a, rc.TimezoneLocation, 120*time.Second) - resolver.SetMemoryRegistry(memoryRegistry) - resolver.SetSkillLoader(&skillLoaderAdapter{handler: containerdHandler}) - resolver.SetGatewayAssetLoader(&gatewayAssetLoaderAdapter{media: mediaService}) - resolver.SetRouteService(routeService) - resolver.SetSessionService(sessionService) - resolver.SetEventPublisher(eventHub) - resolver.SetCompactionService(compactionService) - resolver.SetPipeline(pipeline) - resolver.SetBackgroundManager(bgManager) - bgManager.SetWakeFunc(func(botID, sessionID string) { - resolver.TriggerBackgroundNotification(context.Background(), botID, sessionID) - }) - return resolver -} - -// --------------------------------------------------------------------------- -// channel providers -// --------------------------------------------------------------------------- - -func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService *media.Service) *channel.Registry { - registry := channel.NewRegistry() - - // Telegram - tgAdapter := telegram.NewTelegramAdapter(log) - tgAdapter.SetAssetOpener(mediaService) - registry.MustRegister(tgAdapter) - - // Discord - discordAdapter := discord.NewDiscordAdapter(log) - discordAdapter.SetAssetOpener(mediaService) - registry.MustRegister(discordAdapter) - - qqAdapter := qq.NewQQAdapter(log) - qqAdapter.SetAssetOpener(mediaService) - registry.MustRegister(qqAdapter) - matrixAdapter := matrix.NewMatrixAdapter(log) - matrixAdapter.SetAssetOpener(mediaService) - registry.MustRegister(matrixAdapter) - - feishuAdapter := feishu.NewFeishuAdapter(log) - feishuAdapter.SetAssetOpener(mediaService) - registry.MustRegister(feishuAdapter) - registry.MustRegister(wecom.NewWeComAdapter(log)) - dingTalkAdapter := dingtalk.NewDingTalkAdapter(log) - registry.MustRegister(dingTalkAdapter) - registry.MustRegister(wechatoa.NewWeChatOAAdapter(log)) - weixinAdapter := weixin.NewWeixinAdapter(log) - weixinAdapter.SetAssetOpener(mediaService) - registry.MustRegister(weixinAdapter) - registry.MustRegister(local.NewWebAdapter(hub)) - - // Misskey - registry.MustRegister(misskey.NewMisskeyAdapter(log)) - - return registry -} - -func provideChannelRouter( - log *slog.Logger, - registry *channel.Registry, - hub *local.RouteHub, - routeService *route.DBService, - sessionService *sessionpkg.Service, - msgService *message.DBService, - resolver *flow.Resolver, - identityService *identities.Service, - botService *bots.Service, - aclService *acl.Service, - policyService *policy.Service, - bindService *bind.Service, - mediaService *media.Service, - ttsService *ttspkg.Service, - settingsService *settings.Service, - scheduleService *schedule.Service, - mcpConnService *mcp.ConnectionService, - modelsService *models.Service, - providersService *providers.Service, - memProvService *memprovider.Service, - searchProvService *searchproviders.Service, - browserCtxService *browsercontexts.Service, - emailService *emailpkg.Service, - emailOutboxService *emailpkg.OutboxService, - heartbeatService *heartbeat.Service, - queries *dbsqlc.Queries, - containerdHandler *handlers.ContainerdHandler, - manager *workspace.Manager, - pipeline *pipelinepkg.Pipeline, - eventStore *pipelinepkg.EventStore, - discussDriver *pipelinepkg.DiscussDriver, - rc *boot.RuntimeConfig, -) *inbound.ChannelInboundProcessor { - adapter, ok := registry.Get(qq.Type) - if !ok { - panic("qq adapter not registered") - } - qqAdapter, ok := adapter.(*qq.QQAdapter) - if !ok { - panic("qq adapter has unexpected type") - } - qqAdapter.SetChannelIdentityResolver(identityService) - qqAdapter.SetRouteResolver(routeService) - - processor := inbound.NewChannelInboundProcessor(log, registry, routeService, msgService, resolver, identityService, policyService, bindService, rc.JwtSecret, 5*time.Minute) - processor.SetSessionEnsurer(&sessionEnsurerAdapter{svc: sessionService}) - processor.SetPipeline(pipeline, eventStore, discussDriver) - discussDriver.SetResolver(resolver) - discussDriver.SetBroadcaster(hub) - processor.SetACLService(aclService) - processor.SetMediaService(mediaService) - processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub)) - processor.SetDispatcher(inbound.NewRouteDispatcher(log)) - processor.SetTtsService(ttsService, &settingsTtsModelResolver{settings: settingsService}) - processor.SetCommandHandler(command.NewHandler( - log, - &command.BotMemberRoleAdapter{BotService: botService}, - scheduleService, - settingsService, - mcpConnService, - modelsService, - providersService, - memProvService, - searchProvService, - browserCtxService, - emailService, - emailOutboxService, - heartbeatService, - queries, - aclService, - &commandSkillLoaderAdapter{handler: containerdHandler}, - &commandContainerFSAdapter{manager: manager}, - )) - return processor -} - -func provideChannelManager(log *slog.Logger, registry *channel.Registry, channelStore *channel.Store, channelRouter *inbound.ChannelInboundProcessor, mediaService *media.Service) *channel.Manager { - if adapter, ok := registry.Get(matrix.Type); ok { - if matrixAdapter, ok := adapter.(*matrix.MatrixAdapter); ok { - matrixAdapter.SetSyncStateSaver(channelStore.SaveMatrixSyncSinceToken) - } - } - mgr := channel.NewManager(log, registry, channelStore, channelRouter) - mgr.SetAttachmentStore(mediaService) - if mw := channelRouter.IdentityMiddleware(); mw != nil { - mgr.Use(mw) - } - channelRouter.SetReactor(mgr) - return mgr -} - -func provideChannelLifecycleService(channelStore *channel.Store, channelManager *channel.Manager) *channel.Lifecycle { - return channel.NewLifecycle(channelStore, channelManager) -} - -// --------------------------------------------------------------------------- -// containerd handler & tool gateway -// --------------------------------------------------------------------------- - -func provideContainerdHandler(log *slog.Logger, manager *workspace.Manager, cfg config.Config, rc *boot.RuntimeConfig, botService *bots.Service, accountService *accounts.Service, policyService *policy.Service) *handlers.ContainerdHandler { - return handlers.NewContainerdHandler(log, manager, cfg.Workspace, rc.ContainerBackend, botService, accountService, policyService) -} - -func provideFederationGateway(log *slog.Logger, containerdHandler *handlers.ContainerdHandler) *handlers.MCPFederationGateway { - return handlers.NewMCPFederationGateway(log, containerdHandler) -} - -func provideOAuthService(log *slog.Logger, queries *dbsqlc.Queries, cfg config.Config) *mcp.OAuthService { - addr := strings.TrimSpace(cfg.Server.Addr) - if addr == "" { - addr = ":8080" - } - host := addr - if strings.HasPrefix(host, ":") { - host = "localhost" + host - } - callbackURL := "http://" + host + "/api/oauth/mcp/callback" - return mcp.NewOAuthService(log, queries, callbackURL) -} - -func provideToolGatewayService(log *slog.Logger, fedGateway *handlers.MCPFederationGateway, oauthService *mcp.OAuthService, mcpConnService *mcp.ConnectionService, containerdHandler *handlers.ContainerdHandler) *mcp.ToolGatewayService { - fedGateway.SetOAuthService(oauthService) - fedSource := mcpfederation.NewSource(log, fedGateway, mcpConnService) - svc := mcp.NewToolGatewayService(log, []mcp.ToolSource{fedSource}) - containerdHandler.SetToolGatewayService(svc) - return svc -} - -func provideBackgroundManager(log *slog.Logger) *background.Manager { - return background.New(log) -} - -func provideToolProviders(log *slog.Logger, cfg config.Config, channelManager *channel.Manager, registry *channel.Registry, routeService *route.DBService, scheduleService *schedule.Service, settingsService *settings.Service, searchProviderService *searchproviders.Service, manager *workspace.Manager, mediaService *media.Service, memoryRegistry *memprovider.Registry, emailService *emailpkg.Service, emailManager *emailpkg.Manager, fedGateway *handlers.MCPFederationGateway, mcpConnService *mcp.ConnectionService, modelsService *models.Service, browserContextService *browsercontexts.Service, queries *dbsqlc.Queries, ttsService *ttspkg.Service, sessionService *sessionpkg.Service, bgManager *background.Manager) []agenttools.ToolProvider { - var assetResolver messaging.AssetResolver - if mediaService != nil { - assetResolver = &mediaAssetResolverAdapter{media: mediaService} - } - fedSource := mcpfederation.NewSource(log, fedGateway, mcpConnService) - return []agenttools.ToolProvider{ - agenttools.NewMessageProvider(log, channelManager, channelManager, registry, assetResolver), - agenttools.NewContactsProvider(log, routeService), - agenttools.NewScheduleProvider(log, scheduleService), - agenttools.NewMemoryProvider(log, memoryRegistry, settingsService), - agenttools.NewWebProvider(log, settingsService, searchProviderService), - agenttools.NewContainerProvider(log, manager, bgManager, config.DefaultDataMount), - agenttools.NewEmailProvider(log, emailService, emailManager), - agenttools.NewWebFetchProvider(log), - agenttools.NewSpawnProvider(log, settingsService, modelsService, queries, sessionService), - agenttools.NewSkillProvider(log), - agenttools.NewBrowserProvider(log, settingsService, browserContextService, manager, cfg.BrowserGateway), - agenttools.NewTTSProvider(log, settingsService, ttsService, channelManager, registry), - agenttools.NewImageGenProvider(log, settingsService, modelsService, queries, manager, config.DefaultDataMount), - agenttools.NewFederationProvider(log, fedSource), - agenttools.NewHistoryProvider(log, sessionService, queries), - } -} - -// --------------------------------------------------------------------------- -// handler providers (interface adaptation / config extraction) -// --------------------------------------------------------------------------- - -func provideMemoryHandler(log *slog.Logger, botService *bots.Service, accountService *accounts.Service, _ config.Config, manager *workspace.Manager, memoryRegistry *memprovider.Registry, settingsService *settings.Service, _ *handlers.ContainerdHandler) *handlers.MemoryHandler { - h := handlers.NewMemoryHandler(log, botService, accountService) - h.SetMemoryRegistry(memoryRegistry) - h.SetSettingsService(settingsService) - h.SetMCPClientProvider(manager) - return h -} - -func provideAuthHandler(log *slog.Logger, accountService *accounts.Service, rc *boot.RuntimeConfig) *handlers.AuthHandler { - return handlers.NewAuthHandler(log, accountService, rc.JwtSecret, rc.JwtExpiresIn) -} - -func provideMessageHandler(log *slog.Logger, chatService *conversation.Service, msgService *message.DBService, mediaService *media.Service, botService *bots.Service, accountService *accounts.Service, hub *event.Hub) *handlers.MessageHandler { - h := handlers.NewMessageHandler(log, chatService, msgService, botService, accountService, hub) - h.SetMediaService(mediaService) - return h -} - -func provideSessionHandler(log *slog.Logger, sessionService *sessionpkg.Service, botService *bots.Service, accountService *accounts.Service) *handlers.SessionHandler { - return handlers.NewSessionHandler(log, sessionService, botService, accountService) -} - -func provideMediaService(log *slog.Logger, manager *workspace.Manager, cfg config.Config) *media.Service { - primary := containerfs.New(manager) - dataRoot := cfg.Workspace.DataRoot - if dataRoot == "" { - dataRoot = config.DefaultDataRoot - } - secondary := localfs.New(filepath.Join(dataRoot, "media")) - provider := fallback.New(primary, secondary) - return media.NewService(log, provider) -} - -func provideUsersHandler(log *slog.Logger, accountService *accounts.Service, identityService *identities.Service, botService *bots.Service, routeService *route.DBService, channelStore *channel.Store, channelLifecycle *channel.Lifecycle, channelManager *channel.Manager, registry *channel.Registry) *handlers.UsersHandler { - return handlers.NewUsersHandler(log, accountService, identityService, botService, routeService, channelStore, channelLifecycle, channelManager, registry) -} - -func provideWebHandler(channelManager *channel.Manager, channelStore *channel.Store, chatService *conversation.Service, hub *local.RouteHub, botService *bots.Service, accountService *accounts.Service, resolver *flow.Resolver, mediaService *media.Service, ttsService *ttspkg.Service, settingsService *settings.Service) *handlers.LocalChannelHandler { - h := handlers.NewLocalChannelHandler(local.WebType, channelManager, channelStore, chatService, hub, botService, accountService) - h.SetResolver(resolver) - h.SetMediaService(mediaService) - h.SetTtsService(ttsService, &settingsTtsModelResolver{settings: settingsService}) - return h -} - -// --------------------------------------------------------------------------- -// email providers -// --------------------------------------------------------------------------- - -func provideTtsRegistry(log *slog.Logger) *ttspkg.Registry { - reg := ttspkg.NewRegistry() - reg.Register(ttsedge.NewEdgeAdapter(log)) - return reg -} - -func provideTtsTempStore() (*ttspkg.TempStore, error) { - return ttspkg.NewTempStore(os.TempDir()) -} - -func startTtsTempStoreCleanup(lc fx.Lifecycle, store *ttspkg.TempStore) { - done := make(chan struct{}) - lc.Append(fx.Hook{ - OnStart: func(_ context.Context) error { - go store.StartCleanup(done) - return nil - }, - OnStop: func(_ context.Context) error { - close(done) - return nil - }, - }) -} - -func startBackgroundTaskCleanup(lc fx.Lifecycle, mgr *background.Manager) { - done := make(chan struct{}) - lc.Append(fx.Hook{ - OnStart: func(_ context.Context) error { - go mgr.StartCleanupLoop(done, background.DefaultCleanupInterval, background.DefaultTaskRetention) - return nil - }, - OnStop: func(_ context.Context) error { - close(done) - return nil - }, - }) -} - -// settingsTtsModelResolver adapts settings.Service to the ttsModelResolver interface -// expected by ChannelInboundProcessor and LocalChannelHandler. -// sessionEnsurerAdapter adapts session.Service to the inbound sessionEnsurer interface. -type sessionEnsurerAdapter struct { - svc *sessionpkg.Service -} - -func (a *sessionEnsurerAdapter) EnsureActiveSession(ctx context.Context, botID, routeID, channelType string) (inbound.SessionResult, error) { - sess, err := a.svc.EnsureActiveSession(ctx, botID, routeID, channelType) - if err != nil { - return inbound.SessionResult{}, err - } - return inbound.SessionResult{ID: sess.ID, Type: sess.Type}, nil -} - -func (a *sessionEnsurerAdapter) GetActiveSession(ctx context.Context, routeID string) (inbound.SessionResult, error) { - sess, err := a.svc.GetActiveForRoute(ctx, routeID) - if err != nil { - return inbound.SessionResult{}, err - } - return inbound.SessionResult{ID: sess.ID, Type: sess.Type}, nil -} - -func (a *sessionEnsurerAdapter) CreateNewSession(ctx context.Context, botID, routeID, channelType, sessionType string) (inbound.SessionResult, error) { - sess, err := a.svc.CreateNewSession(ctx, botID, routeID, channelType, sessionType) - if err != nil { - return inbound.SessionResult{}, err - } - return inbound.SessionResult{ID: sess.ID, Type: sess.Type}, nil -} - -type settingsTtsModelResolver struct { - settings *settings.Service -} - -func (r *settingsTtsModelResolver) ResolveTtsModelID(ctx context.Context, botID string) (string, error) { - s, err := r.settings.GetBot(ctx, botID) - if err != nil { - return "", err - } - return s.TtsModelID, nil -} - -func provideEmailRegistry(log *slog.Logger, tokenStore *emailpkg.DBOAuthTokenStore) *emailpkg.Registry { - reg := emailpkg.NewRegistry() - reg.Register(emailgeneric.New(log)) - reg.Register(emailmailgun.New(log)) - reg.Register(emailgmail.New(log, tokenStore)) - return reg -} - -func provideProvidersService(log *slog.Logger, queries *dbsqlc.Queries, cfg config.Config) *providers.Service { - _ = cfg - return providers.NewService(log, queries, defaultProviderOAuthCallbackURL()) -} - -func defaultProviderOAuthCallbackURL() string { - return "http://localhost:1455/auth/callback" -} - -func provideEmailOAuthHandler(log *slog.Logger, service *emailpkg.Service, tokenStore *emailpkg.DBOAuthTokenStore, cfg config.Config) *handlers.EmailOAuthHandler { - addr := strings.TrimSpace(cfg.Server.Addr) - if addr == "" { - addr = ":8080" - } - host := addr - if strings.HasPrefix(host, ":") { - host = "localhost" + host - } - callbackURL := "http://" + host + "/api/email/oauth/callback" - return handlers.NewEmailOAuthHandler(log, service, tokenStore, callbackURL) -} - -func provideEmailChatGateway(resolver *flow.Resolver, queries *dbsqlc.Queries, cfg config.Config, log *slog.Logger) emailpkg.ChatTriggerer { - return flow.NewEmailChatGateway(resolver, queries, cfg.Auth.JWTSecret, log) -} - -func provideEmailTrigger(log *slog.Logger, service *emailpkg.Service, chatTriggerer emailpkg.ChatTriggerer) *emailpkg.Trigger { - return emailpkg.NewTrigger(log, service, chatTriggerer) -} - -func startEmailManager(lc fx.Lifecycle, emailManager *emailpkg.Manager) { - ctx, cancel := context.WithCancel(context.Background()) - lc.Append(fx.Hook{ - OnStart: func(_ context.Context) error { - go func() { - if err := emailManager.Start(ctx); err != nil { - slog.Default().Error("email manager start failed", slog.Any("error", err)) - } - }() - return nil - }, - OnStop: func(stopCtx context.Context) error { - cancel() - emailManager.Stop(stopCtx) - return nil - }, - }) -} - -// --------------------------------------------------------------------------- -// server -// --------------------------------------------------------------------------- - -type serverParams struct { - fx.In - - Logger *slog.Logger - RuntimeConfig *boot.RuntimeConfig - Config config.Config - ServerHandlers []server.Handler `group:"server_handlers"` - ContainerdHandler *handlers.ContainerdHandler -} - -func provideServer(params serverParams) *server.Server { - allHandlers := make([]server.Handler, 0, len(params.ServerHandlers)+1) - allHandlers = append(allHandlers, params.ServerHandlers...) - allHandlers = append(allHandlers, params.ContainerdHandler) - return server.NewServer(params.Logger, params.RuntimeConfig.ServerAddr, params.Config.Auth.JWTSecret, allHandlers...) -} - -// --------------------------------------------------------------------------- -// lifecycle hooks -// --------------------------------------------------------------------------- - -func startRegistrySync(lc fx.Lifecycle, log *slog.Logger, cfg config.Config, queries *dbsqlc.Queries) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - defs, err := registry.Load(log, cfg.Registry.ProvidersPath()) - if err != nil { - log.Warn("registry: failed to load provider definitions", slog.Any("error", err)) - return nil - } - if len(defs) == 0 { - return nil - } - return registry.Sync(ctx, log, queries, defs) - }, - }) -} - -func startMemoryProviderBootstrap(lc fx.Lifecycle, log *slog.Logger, mpService *memprovider.Service, registry *memprovider.Registry) { - mpService.SetRegistry(registry) - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - resp, err := mpService.EnsureDefault(ctx) - if err != nil { - log.Warn("failed to ensure default memory provider", slog.Any("error", err)) - return nil - } - if _, regErr := registry.Instantiate(resp.ID, resp.Provider, resp.Config); regErr != nil { - log.Warn("failed to instantiate default memory provider", slog.Any("error", regErr)) - } else { - log.Info("default memory provider ready", slog.String("id", resp.ID), slog.String("provider", resp.Provider)) - } - return nil - }, - }) -} - -func startSearchProviderBootstrap(lc fx.Lifecycle, log *slog.Logger, spService *searchproviders.Service) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - if err := spService.EnsureDefaults(ctx); err != nil { - log.Warn("failed to ensure default search providers", slog.Any("error", err)) - } - return nil - }, - }) -} - -func startScheduleService(lc fx.Lifecycle, scheduleService *schedule.Service) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - return scheduleService.Bootstrap(ctx) - }, - }) -} - -func startHeartbeatService(lc fx.Lifecycle, heartbeatService *heartbeat.Service) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - return heartbeatService.Bootstrap(ctx) - }, - }) -} - -func wireResolverOutbound(resolver *flow.Resolver, channelManager *channel.Manager) { - resolver.SetOutboundFn(func(ctx context.Context, botID, channelType, target, text string) error { - return channelManager.Send(ctx, botID, channel.ChannelType(channelType), channel.SendRequest{ - Target: target, - Message: channel.Message{Text: text}, - }) - }) -} - -func startChannelManager(lc fx.Lifecycle, channelManager *channel.Manager) { - ctx, cancel := context.WithCancel(context.Background()) - lc.Append(fx.Hook{ - OnStart: func(_ context.Context) error { - channelManager.Start(ctx) - return nil - }, - OnStop: func(stopCtx context.Context) error { - cancel() - return channelManager.Shutdown(stopCtx) - }, - }) -} - -func startContainerReconciliation(lc fx.Lifecycle, manager *workspace.Manager, _ *handlers.ContainerdHandler, _ *mcp.ToolGatewayService) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - go manager.ReconcileContainers(ctx) - return nil - }, - }) -} - -func startServer(lc fx.Lifecycle, logger *slog.Logger, srv *server.Server, shutdowner fx.Shutdowner, cfg config.Config, queries *dbsqlc.Queries, botService *bots.Service, _ *handlers.ContainerdHandler, manager *workspace.Manager, mcpConnService *mcp.ConnectionService, toolGateway *mcp.ToolGatewayService, channelManager *channel.Manager, modelsService *models.Service) { - fmt.Printf("Starting Memoh Agent %s\n", version.GetInfo()) - - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - if err := ensureAdminUser(ctx, logger, queries, cfg); err != nil { - return err - } - botService.SetContainerLifecycle(manager) - botService.SetContainerReachability(func(ctx context.Context, botID string) error { - _, err := manager.MCPClient(ctx, botID) - return err - }) - botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter( - mcpchecker.NewChecker(logger, mcpConnService, toolGateway), - )) - botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter( - channelchecker.NewChecker(logger, channelManager), - )) - botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter( - modelchecker.NewChecker(logger, modelchecker.NewQueriesLookup(queries), modelsService), - )) - - go func() { - if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Error("server failed", slog.Any("error", err)) - _ = shutdowner.Shutdown() - } - }() - return nil - }, - OnStop: func(ctx context.Context) error { - if err := srv.Stop(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("server stop: %w", err) - } - return nil - }, - }) -} - -// --------------------------------------------------------------------------- -// helpers -// --------------------------------------------------------------------------- - -func ensureAdminUser(ctx context.Context, log *slog.Logger, queries *dbsqlc.Queries, cfg config.Config) error { - if queries == nil { - return errors.New("db queries not configured") - } - count, err := queries.CountAccounts(ctx) - if err != nil { - return err - } - if count > 0 { - return nil - } - - username := strings.TrimSpace(cfg.Admin.Username) - password := strings.TrimSpace(cfg.Admin.Password) - email := strings.TrimSpace(cfg.Admin.Email) - if username == "" || password == "" { - return errors.New("admin username/password required in config.toml") - } - if password == "change-your-password-here" { - log.Warn("admin password uses default placeholder; please update config.toml") - } - - hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return err - } - - user, err := queries.CreateUser(ctx, dbsqlc.CreateUserParams{ - IsActive: true, - Metadata: []byte("{}"), - }) - if err != nil { - return fmt.Errorf("create admin user: %w", err) - } - - emailValue := pgtype.Text{Valid: false} - if email != "" { - emailValue = pgtype.Text{String: email, Valid: true} - } - displayName := pgtype.Text{String: username, Valid: true} - dataRoot := pgtype.Text{String: cfg.Workspace.DataRoot, Valid: cfg.Workspace.DataRoot != ""} - - _, err = queries.CreateAccount(ctx, dbsqlc.CreateAccountParams{ - UserID: user.ID, - Username: pgtype.Text{String: username, Valid: true}, - Email: emailValue, - PasswordHash: pgtype.Text{String: string(hashed), Valid: true}, - Role: "admin", - DisplayName: displayName, - AvatarUrl: pgtype.Text{Valid: false}, - IsActive: true, - DataRoot: dataRoot, - }) - if err != nil { - return err - } - log.Info("Admin user created", slog.String("username", username)) - return nil -} - -// --------------------------------------------------------------------------- -// lazy LLM client -// --------------------------------------------------------------------------- - -type lazyLLMClient struct { - modelsService *models.Service - settingsService *settings.Service - queries *dbsqlc.Queries - timeout time.Duration - logger *slog.Logger -} - -func (c *lazyLLMClient) Extract(ctx context.Context, req memprovider.ExtractRequest) (memprovider.ExtractResponse, error) { - client, err := c.resolve(ctx, req.BotID) - if err != nil { - return memprovider.ExtractResponse{}, err - } - return client.Extract(ctx, req) -} - -func (c *lazyLLMClient) Decide(ctx context.Context, req memprovider.DecideRequest) (memprovider.DecideResponse, error) { - client, err := c.resolve(ctx, req.BotID) - if err != nil { - return memprovider.DecideResponse{}, err - } - return client.Decide(ctx, req) -} - -func (c *lazyLLMClient) Compact(ctx context.Context, req memprovider.CompactRequest) (memprovider.CompactResponse, error) { - client, err := c.resolve(ctx, "") - if err != nil { - return memprovider.CompactResponse{}, err - } - return client.Compact(ctx, req) -} - -func (c *lazyLLMClient) DetectLanguage(ctx context.Context, text string) (string, error) { - client, err := c.resolve(ctx, "") - if err != nil { - return "", err - } - return client.DetectLanguage(ctx, text) -} - -func (c *lazyLLMClient) resolve(ctx context.Context, botID string) (memprovider.LLM, error) { - if c.modelsService == nil || c.queries == nil { - return nil, errors.New("models service not configured") - } - - // Try to use the bot's configured chat model for memory operations. - chatModelID := "" - if c.settingsService != nil && strings.TrimSpace(botID) != "" { - if botSettings, err := c.settingsService.GetBot(ctx, botID); err == nil { - // Prefer compaction model (smaller/cheaper), then chat model. - if id := strings.TrimSpace(botSettings.CompactionModelID); id != "" { - chatModelID = id - } else if id := strings.TrimSpace(botSettings.ChatModelID); id != "" { - chatModelID = id - } - } - } - - memoryModel, memoryProvider, err := models.SelectMemoryModelForBot(ctx, c.modelsService, c.queries, chatModelID) - if err != nil { - return nil, err - } - return memllm.New(memllm.Config{ - ModelID: memoryModel.ModelID, - BaseURL: strings.TrimRight(providers.ProviderConfigString(memoryProvider, "base_url"), "/"), - APIKey: providers.ProviderConfigString(memoryProvider, "api_key"), - ClientType: memoryProvider.ClientType, - Timeout: c.timeout, - }), nil -} - -// skillLoaderAdapter bridges handlers.ContainerdHandler to flow.SkillLoader. -type skillLoaderAdapter struct { - handler *handlers.ContainerdHandler -} - -func (a *skillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]flow.SkillEntry, error) { - items, err := a.handler.LoadSkills(ctx, botID) - if err != nil { - return nil, err - } - entries := make([]flow.SkillEntry, len(items)) - for i, item := range items { - entries[i] = flow.SkillEntry{ - Name: item.Name, - Description: item.Description, - Content: item.Content, - Metadata: item.Metadata, - } - } - return entries, nil -} - -// mediaAssetResolverAdapter bridges media.Service to the messaging package's AssetResolver interface. -type mediaAssetResolverAdapter struct { - media *media.Service -} - -func (a *mediaAssetResolverAdapter) Stat(ctx context.Context, botID, contentHash string) (media.Asset, error) { - if a == nil || a.media == nil { - return media.Asset{}, errors.New("media service not configured") - } - return a.media.Stat(ctx, botID, contentHash) -} - -func (a *mediaAssetResolverAdapter) Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error) { - if a == nil || a.media == nil { - return nil, media.Asset{}, errors.New("media service not configured") - } - return a.media.Open(ctx, botID, contentHash) -} - -func (a *mediaAssetResolverAdapter) Ingest(ctx context.Context, input media.IngestInput) (media.Asset, error) { - if a == nil || a.media == nil { - return media.Asset{}, errors.New("media service not configured") - } - return a.media.Ingest(ctx, input) -} - -func (a *mediaAssetResolverAdapter) GetByStorageKey(ctx context.Context, botID, storageKey string) (messaging.AssetMeta, error) { - if a == nil || a.media == nil { - return messaging.AssetMeta{}, errors.New("media service not configured") - } - return a.media.GetByStorageKey(ctx, botID, storageKey) -} - -func (a *mediaAssetResolverAdapter) AccessPath(asset media.Asset) string { - if a == nil || a.media == nil { - return "" - } - return a.media.AccessPath(asset) -} - -func (a *mediaAssetResolverAdapter) IngestContainerFile(ctx context.Context, botID, containerPath string) (messaging.AssetMeta, error) { - if a == nil || a.media == nil { - return messaging.AssetMeta{}, errors.New("media service not configured") - } - return a.media.IngestContainerFile(ctx, botID, containerPath) -} - -// gatewayAssetLoaderAdapter bridges media service to flow gateway asset loader. -type gatewayAssetLoaderAdapter struct { - media *media.Service -} - -func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, contentHash string) (io.ReadCloser, string, error) { - if a == nil || a.media == nil { - return nil, "", errors.New("media service not configured") - } - reader, asset, err := a.media.Open(ctx, botID, contentHash) - if err != nil { - return nil, "", err - } - return reader, strings.TrimSpace(asset.Mime), nil -} - -// commandSkillLoaderAdapter bridges handlers.ContainerdHandler to command.SkillLoader. -type commandSkillLoaderAdapter struct { - handler *handlers.ContainerdHandler -} - -func (a *commandSkillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]command.Skill, error) { - items, err := a.handler.LoadSkills(ctx, botID) - if err != nil { - return nil, err - } - skills := make([]command.Skill, len(items)) - for i, item := range items { - skills[i] = command.Skill{Name: item.Name, Description: item.Description} - } - return skills, nil -} - -// commandContainerFSAdapter bridges workspace.Manager to command.ContainerFS. -type commandContainerFSAdapter struct { - manager *workspace.Manager -} - -func (a *commandContainerFSAdapter) ListDir(ctx context.Context, botID, dirPath string) ([]command.FSEntry, error) { - client, err := a.manager.MCPClient(ctx, botID) - if err != nil { - return nil, err - } - entries, err := client.ListDirAll(ctx, dirPath, false) - if err != nil { - return nil, err - } - result := make([]command.FSEntry, len(entries)) - for i, e := range entries { - name := stdpath.Base(e.GetPath()) - result[i] = command.FSEntry{Name: name, IsDir: e.GetIsDir(), Size: e.GetSize()} - } - return result, nil -} - -func (a *commandContainerFSAdapter) ReadFile(ctx context.Context, botID, filePath string) (string, error) { - client, err := a.manager.MCPClient(ctx, botID) - if err != nil { - return "", err - } - resp, err := client.ReadFile(ctx, filePath, 0, 0) - if err != nil { - return "", err - } - return resp.GetContent(), nil -} diff --git a/cmd/agent/mise.toml b/cmd/agent/mise.toml index 2f323200..cad6500d 100644 --- a/cmd/agent/mise.toml +++ b/cmd/agent/mise.toml @@ -4,5 +4,5 @@ dir = "{{cwd}}" [tasks.start] alias = "dev" description = "Start server" -run = "go run cmd/agent/main.go" +run = "go run cmd/agent" depends = ["//:go-install"] diff --git a/cmd/agent/module.go b/cmd/agent/module.go new file mode 100644 index 00000000..8170d093 --- /dev/null +++ b/cmd/agent/module.go @@ -0,0 +1,160 @@ +package main + +import ( + "log/slog" + + "go.uber.org/fx" + "go.uber.org/fx/fxevent" + + "github.com/memohai/memoh/internal/accounts" + "github.com/memohai/memoh/internal/acl" + "github.com/memohai/memoh/internal/bind" + "github.com/memohai/memoh/internal/boot" + "github.com/memohai/memoh/internal/bots" + "github.com/memohai/memoh/internal/browsercontexts" + "github.com/memohai/memoh/internal/channel" + "github.com/memohai/memoh/internal/channel/adapters/local" + "github.com/memohai/memoh/internal/channel/adapters/weixin" + "github.com/memohai/memoh/internal/channel/identities" + "github.com/memohai/memoh/internal/compaction" + "github.com/memohai/memoh/internal/conversation" + emailpkg "github.com/memohai/memoh/internal/email" + "github.com/memohai/memoh/internal/handlers" + "github.com/memohai/memoh/internal/heartbeat" + "github.com/memohai/memoh/internal/mcp" + memprovider "github.com/memohai/memoh/internal/memory/adapters" + "github.com/memohai/memoh/internal/message/event" + "github.com/memohai/memoh/internal/models" + "github.com/memohai/memoh/internal/policy" + "github.com/memohai/memoh/internal/schedule" + "github.com/memohai/memoh/internal/searchproviders" + "github.com/memohai/memoh/internal/settings" + ttspkg "github.com/memohai/memoh/internal/tts" +) + +func runServe() { + fx.New(options()).Run() +} + +func options() fx.Option { + return fx.Options( + fx.Provide( + provideConfig, + boot.ProvideRuntimeConfig, + provideLogger, + provideContainerService, + provideDBConn, + provideDBQueries, + provideWorkspaceManager, + provideMemoryLLM, + memprovider.NewService, + provideMemoryProviderRegistry, + models.NewService, + bots.NewService, + accounts.NewService, + acl.NewService, + settings.NewService, + provideProvidersService, + searchproviders.NewService, + browsercontexts.NewService, + policy.NewService, + mcp.NewConnectionService, + conversation.NewService, + identities.NewService, + bind.NewService, + event.NewHub, + provideTtsRegistry, + ttspkg.NewService, + provideTtsTempStore, + emailpkg.NewDBOAuthTokenStore, + provideEmailRegistry, + emailpkg.NewService, + emailpkg.NewOutboxService, + provideEmailChatGateway, + provideEmailTrigger, + emailpkg.NewManager, + provideRouteService, + provideSessionService, + provideMessageService, + provideMediaService, + providePipeline, + provideEventStore, + provideDiscussDriver, + local.NewRouteHub, + provideChannelRegistry, + channel.NewStore, + provideChannelRouter, + provideChannelManager, + provideChannelLifecycleService, + provideAgent, + provideChatResolver, + provideScheduleTriggerer, + provideHeartbeatSessionCreator, + provideScheduleSessionCreator, + schedule.NewService, + provideHeartbeatTriggerer, + heartbeat.NewService, + compaction.NewService, + provideContainerdHandler, + provideFederationGateway, + provideToolGatewayService, + provideBackgroundManager, + provideToolProviders, + provideServerHandler(handlers.NewPingHandler), + provideServerHandler(provideAuthHandler), + provideServerHandler(provideMemoryHandler), + provideServerHandler(provideMessageHandler), + provideServerHandler(provideSessionHandler), + provideServerHandler(handlers.NewSwaggerHandler), + provideServerHandler(handlers.NewProvidersHandler), + provideServerHandler(handlers.NewProviderOAuthHandler), + provideServerHandler(handlers.NewSearchProvidersHandler), + provideServerHandler(handlers.NewModelsHandler), + provideServerHandler(handlers.NewSettingsHandler), + provideServerHandler(handlers.NewACLHandler), + provideServerHandler(handlers.NewBindHandler), + provideServerHandler(handlers.NewScheduleHandler), + provideServerHandler(handlers.NewHeartbeatHandler), + provideServerHandler(handlers.NewCompactionHandler), + provideServerHandler(handlers.NewChannelHandler), + provideServerHandler(channel.NewWebhookServerHandler), + provideServerHandler(weixin.NewQRServerHandler), + provideServerHandler(provideUsersHandler), + provideServerHandler(handlers.NewMemoryProvidersHandler), + provideServerHandler(handlers.NewSpeechHandler), + provideServerHandler(handlers.NewBotTtsHandler), + provideServerHandler(handlers.NewEmailProvidersHandler), + provideServerHandler(handlers.NewEmailBindingsHandler), + provideServerHandler(handlers.NewEmailOutboxHandler), + provideServerHandler(handlers.NewEmailWebhookHandler), + provideServerHandler(provideEmailOAuthHandler), + provideServerHandler(handlers.NewMCPHandler), + provideServerHandler(handlers.NewMCPOAuthHandler), + provideOAuthService, + provideServerHandler(handlers.NewTokenUsageHandler), + provideServerHandler(handlers.NewSessionInfoHandler), + provideServerHandler(handlers.NewBrowserContextsHandler), + provideServerHandler(handlers.NewSupermarketHandler), + provideServerHandler(provideWebHandler), + provideServer, + ), + fx.Invoke( + injectToolProviders, + startRegistrySync, + startMemoryProviderBootstrap, + startSearchProviderBootstrap, + startScheduleService, + startHeartbeatService, + wireResolverOutbound, + startChannelManager, + startEmailManager, + startContainerReconciliation, + startBackgroundTaskCleanup, + startTtsTempStoreCleanup, + startServer, + ), + fx.WithLogger(func(logger *slog.Logger) fxevent.Logger { + return &fxevent.SlogLogger{Logger: logger.With(slog.String("component", "fx"))} + }), + ) +} diff --git a/cmd/agent/support.go b/cmd/agent/support.go new file mode 100644 index 00000000..f72b7f1a --- /dev/null +++ b/cmd/agent/support.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "io/fs" + "log/slog" + "os" + + dbembed "github.com/memohai/memoh/db" + "github.com/memohai/memoh/internal/config" + "github.com/memohai/memoh/internal/db" + "github.com/memohai/memoh/internal/logger" + "github.com/memohai/memoh/internal/version" +) + +func provideConfig() (config.Config, error) { + cfgPath := os.Getenv("CONFIG_PATH") + cfg, err := config.Load(cfgPath) + if err != nil { + return config.Config{}, fmt.Errorf("load config: %w", err) + } + return cfg, nil +} + +func migrationsFS() fs.FS { + sub, err := fs.Sub(dbembed.MigrationsFS, "migrations") + if err != nil { + panic(fmt.Sprintf("embedded migrations: %v", err)) + } + return sub +} + +func runMigrateCommand(args []string) error { + cfg, err := provideConfig() + if err != nil { + return fmt.Errorf("config: %w", err) + } + + logger.Init(cfg.Log.Level, cfg.Log.Format) + log := logger.L + + migrateCmd := args[0] + var migrateArgs []string + if len(args) > 1 { + migrateArgs = args[1:] + } + + if err := db.RunMigrate(log, cfg.Postgres, migrationsFS(), migrateCmd, migrateArgs); err != nil { + log.Error("migration failed", slog.Any("error", err)) + return err + } + return nil +} + +func runVersion() error { + fmt.Printf("memoh-server %s\n", version.GetInfo()) + return nil +} diff --git a/cmd/memoh/chat.go b/cmd/memoh/chat.go new file mode 100644 index 00000000..9cfa4702 --- /dev/null +++ b/cmd/memoh/chat.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/memohai/memoh/internal/tui" +) + +func newChatCommand(ctx *cliContext) *cobra.Command { + var botID string + var sessionID string + var message string + + cmd := &cobra.Command{ + Use: "chat", + Short: "Send one chat message and stream the reply", + RunE: func(_ *cobra.Command, _ []string) error { + client := tui.NewClient(ctx.state.ServerURL, ctx.state.Token) + if sessionID == "" { + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + sess, err := client.CreateSession(requestCtx, botID, message) + if err != nil { + return err + } + sessionID = sess.ID + fmt.Printf("session: %s\n", sessionID) + } + + streamCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + return client.StreamChat(streamCtx, tui.ChatRequest{ + BotID: botID, + SessionID: sessionID, + Text: message, + }, func(event tui.ChatEvent) error { + switch event.Type { + case "start": + fmt.Println("[start]") + case "message": + fmt.Println(tui.RenderUIMessage(event.Data)) + case "error": + fmt.Println("[error]", event.Message) + case "end": + fmt.Println("[end]") + } + return nil + }) + }, + } + + cmd.Flags().StringVar(&botID, "bot", "", "Target bot ID") + cmd.Flags().StringVar(&sessionID, "session", "", "Existing session ID") + cmd.Flags().StringVar(&message, "message", "", "User message text") + _ = cmd.MarkFlagRequired("bot") + _ = cmd.MarkFlagRequired("message") + return cmd +} diff --git a/cmd/memoh/login.go b/cmd/memoh/login.go new file mode 100644 index 00000000..ca875e6c --- /dev/null +++ b/cmd/memoh/login.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/memohai/memoh/internal/tui" +) + +func newLoginCommand(ctx *cliContext) *cobra.Command { + var username string + var password string + + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate and persist a local access token", + RunE: func(_ *cobra.Command, _ []string) error { + client := tui.NewClient(ctx.state.ServerURL, "") + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.Login(requestCtx, username, password) + if err != nil { + return err + } + + next := ctx.state + next.ServerURL = client.BaseURL + next.Token = resp.AccessToken + next.Username = resp.Username + if parsed, err := time.Parse(time.RFC3339, resp.ExpiresAt); err == nil { + next.ExpiresAt = parsed + } + if err := tui.SaveState(next); err != nil { + return err + } + + fmt.Printf("Logged in as %s against %s\n", resp.Username, client.BaseURL) + return nil + }, + } + + cmd.Flags().StringVar(&username, "username", "", "Account username") + cmd.Flags().StringVar(&password, "password", "", "Account password") + _ = cmd.MarkFlagRequired("username") + _ = cmd.MarkFlagRequired("password") + return cmd +} diff --git a/cmd/memoh/main.go b/cmd/memoh/main.go index 66b65e3a..463737e5 100644 --- a/cmd/memoh/main.go +++ b/cmd/memoh/main.go @@ -2,47 +2,10 @@ package main import ( "os" - - "github.com/spf13/cobra" ) func main() { - rootCmd := &cobra.Command{ - Use: "memoh", - Short: "Memoh unified binary", - RunE: func(_ *cobra.Command, _ []string) error { - runServe() - return nil - }, - } - - rootCmd.AddCommand(&cobra.Command{ - Use: "serve", - Short: "Start the server", - RunE: func(_ *cobra.Command, _ []string) error { - runServe() - return nil - }, - }) - - rootCmd.AddCommand(&cobra.Command{ - Use: "migrate ", - Short: "Run database migrations", - Args: cobra.MinimumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runMigrate(args) - }, - }) - - rootCmd.AddCommand(&cobra.Command{ - Use: "version", - Short: "Print version information", - RunE: func(_ *cobra.Command, _ []string) error { - return runVersion() - }, - }) - - if err := rootCmd.Execute(); err != nil { + if err := newRootCommand().Execute(); err != nil { os.Exit(1) } } diff --git a/cmd/memoh/migrate.go b/cmd/memoh/migrate.go index a1778afd..d3ed544e 100644 --- a/cmd/memoh/migrate.go +++ b/cmd/memoh/migrate.go @@ -1,41 +1,14 @@ package main -import ( - "fmt" - "io/fs" - "log/slog" +import "github.com/spf13/cobra" - dbembed "github.com/memohai/memoh/db" - "github.com/memohai/memoh/internal/db" - "github.com/memohai/memoh/internal/logger" -) - -func migrationsFS() fs.FS { - sub, err := fs.Sub(dbembed.MigrationsFS, "migrations") - if err != nil { - panic(fmt.Sprintf("embedded migrations: %v", err)) +func newMigrateCommand() *cobra.Command { + return &cobra.Command{ + Use: "migrate ", + Short: "Run database migrations", + Args: cobra.MinimumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runMigrate(args) + }, } - return sub -} - -func runMigrate(args []string) error { - cfg, err := provideConfig() - if err != nil { - return fmt.Errorf("config: %w", err) - } - - logger.Init(cfg.Log.Level, cfg.Log.Format) - log := logger.L - - migrateCmd := args[0] - var migrateArgs []string - if len(args) > 1 { - migrateArgs = args[1:] - } - - if err := db.RunMigrate(log, cfg.Postgres, migrationsFS(), migrateCmd, migrateArgs); err != nil { - log.Error("migration failed", slog.Any("error", err)) - return err - } - return nil } diff --git a/cmd/memoh/root.go b/cmd/memoh/root.go new file mode 100644 index 00000000..6af5dde5 --- /dev/null +++ b/cmd/memoh/root.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/memohai/memoh/internal/tui" +) + +type cliContext struct { + state tui.State + server string +} + +func newRootCommand() *cobra.Command { + ctx := &cliContext{} + + rootCmd := &cobra.Command{ + Use: "memoh", + Short: "Memoh terminal operator CLI", + RunE: func(_ *cobra.Command, _ []string) error { + return runTUI(ctx) + }, + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + state, err := tui.LoadState() + if err != nil { + return err + } + ctx.state = state + if ctx.server != "" { + ctx.state.ServerURL = tui.NormalizeServerURL(ctx.server) + } + return nil + }, + } + + rootCmd.PersistentFlags().StringVar(&ctx.server, "server", "", "Memoh server URL") + + rootCmd.AddCommand(newMigrateCommand()) + rootCmd.AddCommand(newLoginCommand(ctx)) + rootCmd.AddCommand(newChatCommand(ctx)) + rootCmd.AddCommand(&cobra.Command{ + Use: "tui", + Short: "Open the terminal UI", + RunE: func(_ *cobra.Command, _ []string) error { + return runTUI(ctx) + }, + }) + rootCmd.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print version information", + RunE: func(_ *cobra.Command, _ []string) error { + return runVersion() + }, + }) + + return rootCmd +} + +func runTUI(ctx *cliContext) error { + model := tui.NewTUIModel(ctx.state) + program := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := program.Run(); err != nil { + return fmt.Errorf("run tui: %w", err) + } + return nil +} diff --git a/cmd/memoh/support.go b/cmd/memoh/support.go new file mode 100644 index 00000000..00249767 --- /dev/null +++ b/cmd/memoh/support.go @@ -0,0 +1,63 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + + dbembed "github.com/memohai/memoh/db" + "github.com/memohai/memoh/internal/config" + "github.com/memohai/memoh/internal/db" + "github.com/memohai/memoh/internal/logger" + "github.com/memohai/memoh/internal/version" +) + +func provideConfig() (config.Config, error) { + cfgPath := os.Getenv("CONFIG_PATH") + cfg, err := config.Load(cfgPath) + if err != nil { + return config.Config{}, fmt.Errorf("load config: %w", err) + } + return cfg, nil +} + +func migrationsFS() fs.FS { + sub, err := fs.Sub(dbembed.MigrationsFS, "migrations") + if err != nil { + panic(fmt.Sprintf("embedded migrations: %v", err)) + } + return sub +} + +func runMigrate(args []string) error { + if len(args) == 0 { + return errors.New("usage: migrate ") + } + + cfg, err := provideConfig() + if err != nil { + return fmt.Errorf("config: %w", err) + } + + logger.Init(cfg.Log.Level, cfg.Log.Format) + log := logger.L + + migrateCmd := args[0] + var migrateArgs []string + if len(args) > 1 { + migrateArgs = args[1:] + } + + if err := db.RunMigrate(log, cfg.Postgres, migrationsFS(), migrateCmd, migrateArgs); err != nil { + log.Error("migration failed", slog.Any("error", err)) + return err + } + return nil +} + +func runVersion() error { + fmt.Printf("memoh %s\n", version.GetInfo()) + return nil +} diff --git a/cmd/memoh/version.go b/cmd/memoh/version.go deleted file mode 100644 index 74833b32..00000000 --- a/cmd/memoh/version.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/memohai/memoh/internal/version" -) - -func runVersion() error { - fmt.Printf("memoh %s\n", version.GetInfo()) - return nil -} diff --git a/devenv/docker-compose.minify.yml b/devenv/docker-compose.minify.yml index f74dc75a..97d7f211 100644 --- a/devenv/docker-compose.minify.yml +++ b/devenv/docker-compose.minify.yml @@ -54,7 +54,7 @@ services: container_name: memoh-dev-migrate working_dir: /workspace entrypoint: [] - command: ["go", "run", "./cmd/agent/main.go", "migrate", "up"] + command: ["go", "run", "./cmd/agent", "migrate", "up"] environment: CONFIG_PATH: /workspace/devenv/app.dev.toml GOFLAGS: -buildvcs=false diff --git a/devenv/docker-compose.yml b/devenv/docker-compose.yml index 84be0f10..ff634433 100644 --- a/devenv/docker-compose.yml +++ b/devenv/docker-compose.yml @@ -54,7 +54,7 @@ services: container_name: memoh-dev-migrate working_dir: /workspace entrypoint: [] - command: ["go", "run", "./cmd/agent/main.go", "migrate", "up"] + command: ["go", "run", "./cmd/agent", "migrate", "up"] environment: CONFIG_PATH: /workspace/devenv/app.dev.toml GOFLAGS: -buildvcs=false diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index ec86d693..0246bf8c 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -33,7 +33,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -X github.com/memohai/memoh/internal/version.Version=${VERSION} \ -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH} \ -X github.com/memohai/memoh/internal/version.BuildTime=${BUILD_TIME}" \ - -o memoh-server ./cmd/agent/main.go + -o memoh-server ./cmd/agent # ---- Stage 3: Build bridge binary ---- FROM build-base AS bridge-builder diff --git a/go.mod b/go.mod index 339c4bdf..638a980e 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,11 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 github.com/bwmarrin/discordgo v0.29.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/coder/websocket v1.8.14 github.com/containerd/containerd/api v1.10.0 github.com/containerd/containerd/v2 v2.2.1 github.com/containerd/errdefs v1.0.0 @@ -55,9 +60,21 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/containerd/cgroups/v3 v3.1.2 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -71,7 +88,9 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/emersion/go-message v0.18.2 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect @@ -95,6 +114,7 @@ require ( github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -104,8 +124,12 @@ require ( github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect @@ -115,6 +139,10 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 // indirect @@ -122,6 +150,8 @@ require ( github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sasha-s/go-deadlock v0.3.6 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -129,7 +159,9 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect @@ -145,6 +177,7 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect diff --git a/go.sum b/go.sum index 9d9018a5..c8e89d0b 100644 --- a/go.sum +++ b/go.sum @@ -20,10 +20,24 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= @@ -31,8 +45,36 @@ github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= @@ -74,6 +116,8 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -90,6 +134,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -187,6 +233,8 @@ github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -195,6 +243,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -215,6 +265,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo-jwt/v4 v4.4.0 h1:nrXaEnJupfc2R4XChcLRDyghhMZup77F8nIzHnBK19U= github.com/labstack/echo-jwt/v4 v4.4.0/go.mod h1:kYXWgWms9iFqI3ldR+HAEj/Zfg5rZtR7ePOgktG4Hjg= github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24= @@ -227,19 +279,28 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailgun/mailgun-go/v5 v5.14.0 h1:s1S7MbO0G24zew1cCvTGUA7yvJNf9T/HEiVElNrwF1k= github.com/mailgun/mailgun-go/v5 v5.14.0/go.mod h1:8jl24zvg8DPd5R3dUGIM77J76CWE+esAO+3w0/1c9AA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/memohai/acgo v0.0.0-20260221232113-babac0d6acd7 h1:beehwOQperqGWj4m4EhcPhnSZKtDiuHK/7ZMoTPaQjw= github.com/memohai/acgo v0.0.0-20260221232113-babac0d6acd7/go.mod h1:OvmxM7JmnXBmwJWWVqtreL3HSHSKuzPbtbhlg5MvBg0= github.com/memohai/dingtalk-stream-sdk-go v0.0.0-20260405113102-87e23096b978 h1:6gD8DvZkimGmU0e3PjlusJPyw55SyeoE12CZQoYUa8g= github.com/memohai/dingtalk-stream-sdk-go v0.0.0-20260405113102-87e23096b978/go.mod h1:2LMgK5QYFlTSvrGY+sI/j+jK2WK+YGHv4IMuiW+iPSc= github.com/memohai/twilight-ai v0.3.4-0.20260412161211-dbedfe32c86f h1:9NAj+FyDJPi8RzD1PUwb6OxZx/OrBD2FJo4tVAlhpbs= github.com/memohai/twilight-ai v0.3.4-0.20260412161211-dbedfe32c86f/go.mod h1:1uNfZWc8du+HWJ3r3FLyeGAXGiUAniuSWV89A8gbcz0= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= @@ -268,6 +329,14 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= @@ -299,11 +368,16 @@ github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlT github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+3g= github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw= github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= @@ -349,6 +423,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -356,6 +432,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -453,6 +531,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -473,6 +552,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/internal/containerd/timezone.go b/internal/containerd/timezone.go index 44c77093..64b21f6e 100644 --- a/internal/containerd/timezone.go +++ b/internal/containerd/timezone.go @@ -2,23 +2,36 @@ package containerd import ( "os" + "path/filepath" + "strings" ) -// TimezoneSpec returns mount specs and environment variables that propagate the host -// timezone into the container via /etc/localtime bind-mount and TZ environment variable. +// TimezoneSpec returns environment variables that propagate the host timezone +// into the container without relying on file bind mounts like /etc/localtime. +// File mounts can fail for workspace containers when the target path is absent +// in the unpacked rootfs, while TZ is sufficient for Go, Node, and most tools. func TimezoneSpec() ([]MountSpec, []string) { - var mounts []MountSpec var env []string - if _, err := os.Stat("/etc/localtime"); err == nil { - mounts = append(mounts, MountSpec{ - Destination: "/etc/localtime", - Type: "bind", - Source: "/etc/localtime", - Options: []string{"rbind", "ro"}, - }) - } - if tz := os.Getenv("TZ"); tz != "" { + if tz := detectTimezone(); tz != "" { env = append(env, "TZ="+tz) } - return mounts, env + return nil, env +} + +func detectTimezone() string { + if tz := strings.TrimSpace(os.Getenv("TZ")); tz != "" { + return tz + } + if data, err := os.ReadFile("/etc/timezone"); err == nil { + if tz := strings.TrimSpace(string(data)); tz != "" { + return tz + } + } + if target, err := filepath.EvalSymlinks("/etc/localtime"); err == nil { + const zoneinfoPrefix = "/usr/share/zoneinfo/" + if strings.HasPrefix(target, zoneinfoPrefix) { + return strings.TrimPrefix(target, zoneinfoPrefix) + } + } + return "" } diff --git a/internal/containerd/timezone_test.go b/internal/containerd/timezone_test.go index 650d8da7..0ffcfd13 100644 --- a/internal/containerd/timezone_test.go +++ b/internal/containerd/timezone_test.go @@ -1,17 +1,14 @@ package containerd import ( - "os" "testing" ) func TestTimezoneSpec_WithTZ(t *testing.T) { t.Setenv("TZ", "Asia/Shanghai") mounts, env := TimezoneSpec() - if _, err := os.Stat("/etc/localtime"); err == nil { - if len(mounts) < 1 { - t.Fatal("expected at least one mount when /etc/localtime exists") - } + if len(mounts) != 0 { + t.Fatalf("expected no mounts, got %d", len(mounts)) } if len(env) == 0 { t.Fatal("expected at least one env var when TZ is set") @@ -20,11 +17,8 @@ func TestTimezoneSpec_WithTZ(t *testing.T) { func TestTimezoneSpec_WithoutTZ(t *testing.T) { t.Setenv("TZ", "") - mounts, env := TimezoneSpec() - if len(env) != 0 { - t.Fatalf("expected no env when TZ empty, got %d", len(env)) - } - if _, err := os.Stat("/etc/localtime"); err != nil && len(mounts) != 0 { - t.Fatalf("expected no mounts when /etc/localtime absent and TZ empty, got %d", len(mounts)) + mounts, _ := TimezoneSpec() + if len(mounts) != 0 { + t.Fatalf("expected no mounts, got %d", len(mounts)) } } diff --git a/internal/db/migrate.go b/internal/db/migrate.go index e9459ba4..11aefc76 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -14,6 +14,11 @@ import ( "github.com/memohai/memoh/internal/config" ) +type MigrationStatus struct { + Version uint + Dirty bool +} + // RunMigrate applies or rolls back database migrations. // The migrationsFS should contain .sql files at its root (not in a subdirectory). // Supported commands: "up", "down", "version", "force N". @@ -76,6 +81,31 @@ func RunMigrate(logger *slog.Logger, cfg config.PostgresConfig, migrationsFS fs. return nil } +func ReadMigrationStatus(cfg config.PostgresConfig, migrationsFS fs.FS) (MigrationStatus, error) { + sourceDriver, err := iofs.New(migrationsFS, ".") + if err != nil { + return MigrationStatus{}, fmt.Errorf("migration source: %w", err) + } + + m, err := migrate.NewWithSourceInstance("iofs", sourceDriver, DSN(cfg)) + if err != nil { + return MigrationStatus{}, fmt.Errorf("migrate init: %w", err) + } + defer func() { _, _ = m.Close() }() + + ver, dirty, err := m.Version() + if err != nil { + if errors.Is(err, migrate.ErrNilVersion) { + return MigrationStatus{}, nil + } + return MigrationStatus{}, fmt.Errorf("migrate version: %w", err) + } + return MigrationStatus{ + Version: ver, + Dirty: dirty, + }, nil +} + type migrateLogger struct { logger *slog.Logger } diff --git a/internal/tui/api.go b/internal/tui/api.go new file mode 100644 index 00000000..064486ca --- /dev/null +++ b/internal/tui/api.go @@ -0,0 +1,249 @@ +package tui + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "github.com/memohai/memoh/internal/bots" + "github.com/memohai/memoh/internal/conversation" + messagepkg "github.com/memohai/memoh/internal/message" + "github.com/memohai/memoh/internal/session" +) + +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` //nolint:gosec // CLI needs to persist and reuse the JWT access token + TokenType string `json:"token_type"` + ExpiresAt string `json:"expires_at"` + UserID string `json:"user_id"` + Role string `json:"role"` + DisplayName string `json:"display_name"` + Username string `json:"username"` + Timezone string `json:"timezone,omitempty"` +} + +type ChatRequest struct { + BotID string + SessionID string + Text string + ModelID string + ReasoningEffort string +} + +type ChatEvent struct { + Type string + Message string + Data conversation.UIMessage +} + +func NewClient(baseURL, token string) *Client { + return &Client{ + BaseURL: NormalizeServerURL(baseURL), + Token: strings.TrimSpace(token), + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) Login(ctx context.Context, username, password string) (LoginResponse, error) { + var resp LoginResponse + err := c.doJSON(ctx, http.MethodPost, "/auth/login", map[string]string{ + "username": username, + "password": password, + }, &resp) + return resp, err +} + +func (c *Client) ListBots(ctx context.Context) ([]bots.Bot, error) { + var resp bots.ListBotsResponse + err := c.doJSON(ctx, http.MethodGet, "/bots", nil, &resp) + return resp.Items, err +} + +func (c *Client) ListSessions(ctx context.Context, botID string) ([]session.Session, error) { + var resp struct { + Items []session.Session `json:"items"` + } + err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/bots/%s/sessions", botID), nil, &resp) + return resp.Items, err +} + +func (c *Client) CreateSession(ctx context.Context, botID, title string) (session.Session, error) { + var resp session.Session + err := c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/bots/%s/sessions", botID), map[string]string{ + "title": title, + }, &resp) + return resp, err +} + +func (c *Client) ListMessages(ctx context.Context, botID, sessionID string) ([]conversation.UITurn, error) { + path := fmt.Sprintf("/bots/%s/messages?format=ui", botID) + if strings.TrimSpace(sessionID) != "" { + path += "&session_id=" + url.QueryEscape(sessionID) + } + var resp struct { + Items []conversation.UITurn `json:"items"` + } + err := c.doJSON(ctx, http.MethodGet, path, nil, &resp) + return resp.Items, err +} + +func (c *Client) ListRawMessages(ctx context.Context, botID, sessionID string) ([]messagepkg.Message, error) { + path := fmt.Sprintf("/bots/%s/messages", botID) + if strings.TrimSpace(sessionID) != "" { + path += "?session_id=" + url.QueryEscape(sessionID) + } + var resp struct { + Items []messagepkg.Message `json:"items"` + } + err := c.doJSON(ctx, http.MethodGet, path, nil, &resp) + return resp.Items, err +} + +func (c *Client) StreamChat(ctx context.Context, req ChatRequest, onEvent func(ChatEvent) error) error { + if strings.TrimSpace(c.Token) == "" { + return errors.New("missing access token") + } + u, err := url.Parse(c.BaseURL) + if err != nil { + return fmt.Errorf("parse base url: %w", err) + } + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + } + u.Path = fmt.Sprintf("/bots/%s/web/ws", req.BotID) + q := u.Query() + q.Set("token", c.Token) + u.RawQuery = q.Encode() + + conn, resp, err := websocket.Dial(ctx, u.String(), nil) + if err != nil { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + return fmt.Errorf("dial websocket: %w", err) + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + payload := map[string]string{ + "type": "message", + "text": req.Text, + "session_id": req.SessionID, + } + if strings.TrimSpace(req.ModelID) != "" { + payload["model_id"] = req.ModelID + } + if strings.TrimSpace(req.ReasoningEffort) != "" { + payload["reasoning_effort"] = req.ReasoningEffort + } + if err := wsjson.Write(ctx, conn, payload); err != nil { + return fmt.Errorf("write websocket request: %w", err) + } + + for { + var envelope struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + } + if err := wsjson.Read(ctx, conn, &envelope); err != nil { + if websocket.CloseStatus(err) == websocket.StatusNormalClosure { + return nil + } + return fmt.Errorf("read websocket event: %w", err) + } + + switch envelope.Type { + case "start", "end": + if err := onEvent(ChatEvent{Type: envelope.Type}); err != nil { + return err + } + if envelope.Type == "end" { + return nil + } + case "error": + if err := onEvent(ChatEvent{Type: "error", Message: envelope.Message}); err != nil { + return err + } + return errors.New(strings.TrimSpace(envelope.Message)) + case "message": + var uiMessage conversation.UIMessage + if err := json.Unmarshal(envelope.Data, &uiMessage); err != nil { + return fmt.Errorf("decode chat message: %w", err) + } + if err := onEvent(ChatEvent{Type: "message", Data: uiMessage}); err != nil { + return err + } + } + } +} + +func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any) error { + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + reader = bytes.NewReader(payload) + } + + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if strings.TrimSpace(c.Token) != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) //nolint:gosec // BaseURL is user-controlled CLI config by design + if err != nil { + return fmt.Errorf("perform request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= 400 { + message := strings.TrimSpace(string(data)) + if message == "" { + message = resp.Status + } + return fmt.Errorf("%s", message) + } + if out == nil || len(data) == 0 { + return nil + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return nil +} diff --git a/internal/tui/store.go b/internal/tui/store.go new file mode 100644 index 00000000..dcf41815 --- /dev/null +++ b/internal/tui/store.go @@ -0,0 +1,90 @@ +package tui + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + DefaultProdServerURL = "http://127.0.0.1:8080" + DefaultDevServerURL = "http://127.0.0.1:18080" +) + +type State struct { + ServerURL string `json:"server_url"` + Token string `json:"token,omitempty"` + Username string `json:"username,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +func DefaultState() State { + return State{ServerURL: DefaultProdServerURL} +} + +func LoadState() (State, error) { + path, err := statePath() + if err != nil { + return State{}, err + } + + data, err := os.ReadFile(path) //nolint:gosec // path is derived from the user's config directory, not arbitrary input + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return DefaultState(), nil + } + return State{}, fmt.Errorf("read state: %w", err) + } + + state := DefaultState() + if err := json.Unmarshal(data, &state); err != nil { + return State{}, fmt.Errorf("decode state: %w", err) + } + state.ServerURL = NormalizeServerURL(state.ServerURL) + return state, nil +} + +func SaveState(state State) error { + path, err := statePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + if strings.TrimSpace(state.ServerURL) == "" { + state.ServerURL = DefaultProdServerURL + } + state.ServerURL = NormalizeServerURL(state.ServerURL) + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("encode state: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write state: %w", err) + } + return nil +} + +func NormalizeServerURL(raw string) string { + trimmed := strings.TrimRight(strings.TrimSpace(raw), "/") + if trimmed == "" { + return DefaultProdServerURL + } + if !strings.Contains(trimmed, "://") { + return "http://" + trimmed + } + return trimmed +} + +func statePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user config dir: %w", err) + } + return filepath.Join(dir, "memoh", "cli.json"), nil +} diff --git a/internal/tui/support.go b/internal/tui/support.go new file mode 100644 index 00000000..27fd08fa --- /dev/null +++ b/internal/tui/support.go @@ -0,0 +1,27 @@ +package tui + +import ( + "fmt" + "io/fs" + "os" + + dbembed "github.com/memohai/memoh/db" + "github.com/memohai/memoh/internal/config" +) + +func ProvideConfig() (config.Config, error) { + cfgPath := os.Getenv("CONFIG_PATH") + cfg, err := config.Load(cfgPath) + if err != nil { + return config.Config{}, fmt.Errorf("load config: %w", err) + } + return cfg, nil +} + +func MigrationsFS() fs.FS { + sub, err := fs.Sub(dbembed.MigrationsFS, "migrations") + if err != nil { + panic(fmt.Sprintf("embedded migrations: %v", err)) + } + return sub +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 00000000..cf7aaf03 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,844 @@ +package tui + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + + "github.com/memohai/memoh/internal/conversation" + dbpkg "github.com/memohai/memoh/internal/db" + "github.com/memohai/memoh/internal/session" +) + +type focusArea int + +const ( + focusBots focusArea = iota + focusSessions + focusChat + focusInput +) + +type TUIModel struct { + client *Client + state State + + panelWidth int + width int + height int + + focus focusArea + + bots []botSummary + botList list.Model + sessions []session.Session + sessList list.Model + + input textinput.Model + viewport viewport.Model + + status string + dbStatus string + + chatContent string + viewportContent string + streamPreview string + streamPreviewOrder []int + streamPreviewItems map[int]conversation.UIMessage + streamCh <-chan ChatEvent +} + +type botSummary struct { + ID string + DisplayName string + Status string +} + +type selectorItem struct { + id string + title string +} + +func (i selectorItem) FilterValue() string { return i.title + " " + i.id } +func (i selectorItem) Title() string { return i.title } +func (selectorItem) Description() string { return "" } + +var ( + memohPrimary = lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"} + mutedBorder = lipgloss.AdaptiveColor{Light: "#D7D8E0", Dark: "#3A3442"} + mutedTitle = lipgloss.AdaptiveColor{Light: "#666978", Dark: "#9A97A3"} +) + +type dbStatusMsg struct { + value string + err error +} + +type botsLoadedMsg struct { + items []botSummary + err error +} + +type sessionsLoadedMsg struct { + items []session.Session + err error +} + +type turnsLoadedMsg struct { + content string + err error +} + +type chatStartedMsg struct { + sessionID string + streamCh <-chan ChatEvent + err error +} + +type chatEventMsg struct { + event ChatEvent +} + +type chatDoneMsg struct{} + +func NewTUIModel(state State) *TUIModel { + input := textinput.New() + input.Placeholder = "Type a message and press Enter" + input.Focus() + + botList := newSelectorList() + sessList := newSelectorList() + + return &TUIModel{ + client: NewClient(state.ServerURL, state.Token), + state: state, + focus: focusBots, + input: input, + viewport: viewport.New(80, 20), + status: "Loading environment status...", + dbStatus: "checking", + botList: botList, + sessList: sessList, + streamPreviewItems: map[int]conversation.UIMessage{}, + } +} + +func (m *TUIModel) Init() tea.Cmd { + return tea.Batch( + loadDBStatusCmd(), + loadBotsCmd(m.client), + ) +} + +func (m *TUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.panelWidth = max(30, msg.Width-6) + listWidth := max(20, m.panelWidth-2) + chatWidth := max(18, m.panelWidth-4) + listHeight := max(4, min(8, max(4, msg.Height/5))) + m.botList.SetSize(listWidth, listHeight) + m.sessList.SetSize(listWidth, listHeight) + m.viewport.Width = chatWidth + m.viewport.Height = max(8, msg.Height-(listHeight*2)-14) + m.input.Width = listWidth + m.syncViewport(false) + return m, nil + + case dbStatusMsg: + if msg.err != nil { + m.dbStatus = "unavailable" + m.status = "DB status unavailable: " + msg.err.Error() + } else { + m.dbStatus = msg.value + } + return m, nil + + case botsLoadedMsg: + if msg.err != nil { + m.status = "Failed to load bots: " + msg.err.Error() + return m, nil + } + m.bots = msg.items + m.syncBotList() + if len(m.bots) > 0 { + m.status = "Use Tab to switch focus. Enter on bots/sessions. Enter in input sends." + return m, loadSessionsCmd(m.client, m.currentBotID()) + } + if strings.TrimSpace(m.state.Token) == "" { + m.status = "Login first with `memoh login`, then reopen the TUI." + } else { + m.status = "No accessible bots found." + } + return m, nil + + case sessionsLoadedMsg: + if msg.err != nil { + m.status = "Failed to load sessions: " + msg.err.Error() + return m, nil + } + m.sessions = msg.items + m.syncSessionList() + if current := m.currentSessionID(); current != "" { + return m, loadTurnsCmd(m.client, m.currentBotID(), current) + } + m.chatContent = "" + m.clearStreamPreview() + m.viewport.SetContent("") + return m, nil + + case turnsLoadedMsg: + if msg.err != nil { + m.status = "Failed to load messages: " + msg.err.Error() + return m, nil + } + m.chatContent = msg.content + m.clearStreamPreview() + m.syncViewport(true) + return m, nil + + case chatStartedMsg: + if msg.err != nil { + m.status = "Failed to start chat: " + msg.err.Error() + return m, nil + } + m.streamCh = msg.streamCh + m.clearStreamPreview() + if msg.sessionID != "" && msg.sessionID != m.currentSessionID() { + return m, tea.Batch( + loadSessionsCmd(m.client, m.currentBotID()), + waitForChatEventCmd(msg.streamCh), + ) + } + return m, waitForChatEventCmd(msg.streamCh) + + case chatEventMsg: + switch msg.event.Type { + case "start": + m.status = "Streaming reply..." + case "message": + m.updateStreamPreview(msg.event.Data) + m.syncViewport(true) + case "error": + m.status = "Chat error: " + msg.event.Message + case "end": + m.status = "Reply finished." + } + return m, waitForChatEventCmd(m.streamCh) + + case chatDoneMsg: + m.streamCh = nil + return m, loadTurnsCmd(m.client, m.currentBotID(), m.currentSessionID()) + + case tea.KeyMsg: + if m.focus == focusChat { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "tab": + m.focus = nextFocus(m.focus) + cmd := m.input.Focus() + return m, cmd + case "shift+tab": + m.focus = prevFocus(m.focus) + return m, nil + case "esc", "q": + m.focus = focusInput + cmd := m.input.Focus() + return m, cmd + case "down", "j": + m.viewport.ScrollDown(1) + return m, nil + case "up", "k": + m.viewport.ScrollUp(1) + return m, nil + case "pgdown", "f": + m.viewport.PageDown() + return m, nil + case "pgup", "b": + m.viewport.PageUp() + return m, nil + case "ctrl+d": + m.viewport.HalfPageDown() + return m, nil + case "ctrl+u": + m.viewport.HalfPageUp() + return m, nil + case "home", "g": + m.viewport.GotoTop() + return m, nil + case "end", "G": + m.viewport.GotoBottom() + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + if m.focus == focusInput { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "tab": + m.focus = nextFocus(m.focus) + m.input.Blur() + return m, nil + case "shift+tab": + m.focus = prevFocus(m.focus) + m.input.Blur() + return m, nil + case "esc": + m.focus = focusChat + m.input.Blur() + return m, nil + case "enter": + text := strings.TrimSpace(m.input.Value()) + if text == "" { + return m, nil + } + if m.currentBotID() == "" { + m.status = "Select a bot first." + return m, nil + } + m.appendTranscript(renderTurnMarkdown(conversation.UITurn{ + Role: "user", + Text: text, + })) + m.input.SetValue("") + return m, startChatCmd(m.client, m.currentBotID(), m.currentSessionID(), text) + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + } + + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "tab": + m.focus = nextFocus(m.focus) + if m.focus == focusInput { + cmd := m.input.Focus() + return m, cmd + } + m.input.Blur() + return m, nil + case "shift+tab": + m.focus = prevFocus(m.focus) + if m.focus == focusInput { + cmd := m.input.Focus() + return m, cmd + } + m.input.Blur() + return m, nil + case "enter": + switch m.focus { + case focusBots: + return m, loadSessionsCmd(m.client, m.currentBotID()) + case focusSessions: + if current := m.currentSessionID(); current != "" { + return m, loadTurnsCmd(m.client, m.currentBotID(), current) + } + return m, nil + case focusChat: + m.viewport.GotoBottom() + return m, nil + } + case "up", "k": + // handled by list models below when focused + case "down", "j": + // handled by list models below when focused + } + } + + switch m.focus { + case focusBots: + var cmd tea.Cmd + m.botList, cmd = m.botList.Update(msg) + return m, cmd + case focusSessions: + var cmd tea.Cmd + m.sessList, cmd = m.sessList.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *TUIModel) View() string { + header := lipgloss.NewStyle().Bold(true).Render("memoh terminal ui") + status := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(m.status) + focusHint := "focus=bots" + switch m.focus { + case focusSessions: + focusHint = "focus=sessions" + case focusChat: + focusHint = "focus=chat (j/k pgup/pgdn ctrl+u/d g/G esc)" + case focusInput: + focusHint = "focus=input" + } + envBlock := panel("Status", strings.Join([]string{ + header, + "server: " + m.client.BaseURL, + "db: " + emptyFallback(m.dbStatus, "checking"), + focusHint, + status, + }, "\n"), false, m.panelWidth) + + return lipgloss.JoinVertical(lipgloss.Left, + envBlock, + panel("Bots", m.botList.View(), m.focus == focusBots, m.panelWidth), + panel("Sessions", m.sessList.View(), m.focus == focusSessions, m.panelWidth), + panel("Chat", m.renderChatViewport(), m.focus == focusChat, m.panelWidth), + panel("Input", m.input.View(), m.focus == focusInput, m.panelWidth), + ) +} + +func loadDBStatusCmd() tea.Cmd { + return func() tea.Msg { + cfg, err := ProvideConfig() + if err != nil { + return dbStatusMsg{err: err} + } + status, err := dbpkg.ReadMigrationStatus(cfg.Postgres, MigrationsFS()) + if err != nil { + return dbStatusMsg{err: err} + } + return dbStatusMsg{value: fmt.Sprintf("version=%d dirty=%t", status.Version, status.Dirty)} + } +} + +func loadBotsCmd(client *Client) tea.Cmd { + return func() tea.Msg { + if strings.TrimSpace(client.Token) == "" { + return botsLoadedMsg{} + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + items, err := client.ListBots(ctx) + if err != nil { + return botsLoadedMsg{err: err} + } + result := make([]botSummary, 0, len(items)) + for _, item := range items { + result = append(result, botSummary{ + ID: item.ID, + DisplayName: item.DisplayName, + Status: item.Status, + }) + } + return botsLoadedMsg{items: result} + } +} + +func loadSessionsCmd(client *Client, botID string) tea.Cmd { + return func() tea.Msg { + if strings.TrimSpace(botID) == "" { + return sessionsLoadedMsg{} + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + items, err := client.ListSessions(ctx, botID) + return sessionsLoadedMsg{items: items, err: err} + } +} + +func loadTurnsCmd(client *Client, botID, sessionID string) tea.Cmd { + return func() tea.Msg { + if strings.TrimSpace(botID) == "" || strings.TrimSpace(sessionID) == "" { + return turnsLoadedMsg{} + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + items, err := client.ListMessages(ctx, botID, sessionID) + if err != nil { + return turnsLoadedMsg{err: err} + } + lines := make([]string, 0, len(items)) + for _, turn := range items { + lines = append(lines, renderTurnMarkdown(turn)) + } + return turnsLoadedMsg{content: strings.Join(lines, "\n\n")} + } +} + +func startChatCmd(client *Client, botID, sessionID, text string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + activeSessionID := strings.TrimSpace(sessionID) + if activeSessionID == "" { + sess, err := client.CreateSession(ctx, botID, text) + if err != nil { + return chatStartedMsg{err: err} + } + activeSessionID = sess.ID + } + + streamCh := make(chan ChatEvent, 32) + go func() { + defer close(streamCh) + err := client.StreamChat(context.Background(), ChatRequest{ + BotID: botID, + SessionID: activeSessionID, + Text: text, + }, func(event ChatEvent) error { + streamCh <- event + return nil + }) + if err != nil { + streamCh <- ChatEvent{Type: "error", Message: err.Error()} + } + }() + + return chatStartedMsg{ + sessionID: activeSessionID, + streamCh: streamCh, + } + } +} + +func waitForChatEventCmd(ch <-chan ChatEvent) tea.Cmd { + return func() tea.Msg { + if ch == nil { + return chatDoneMsg{} + } + event, ok := <-ch + if !ok { + return chatDoneMsg{} + } + return chatEventMsg{event: event} + } +} + +func newSelectorList() list.Model { + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle. + Foreground(memohPrimary). + BorderForeground(memohPrimary). + Bold(true) + delegate.Styles.NormalTitle = delegate.Styles.NormalTitle.Foreground(lipgloss.Color("252")) + delegate.Styles.DimmedTitle = delegate.Styles.DimmedTitle.Foreground(mutedTitle) + + l := list.New([]list.Item{}, delegate, 0, 0) + l.SetShowTitle(false) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + l.SetFilteringEnabled(false) + l.DisableQuitKeybindings() + l.KeyMap.Quit.SetEnabled(false) + l.KeyMap.ForceQuit.SetEnabled(false) + l.Styles.NoItems = lipgloss.NewStyle().Foreground(mutedTitle) + return l +} + +func (m *TUIModel) syncBotList() { + selectedID := m.currentBotID() + items := make([]list.Item, 0, len(m.bots)) + selectedIdx := 0 + for i, item := range m.bots { + entry := selectorItem{ + id: item.ID, + title: item.DisplayName, + } + items = append(items, entry) + if item.ID == selectedID { + selectedIdx = i + } + } + m.botList.SetItems(items) + if len(items) > 0 { + m.botList.Select(selectedIdx) + } +} + +func (m *TUIModel) syncSessionList() { + selectedID := m.currentSessionID() + items := make([]list.Item, 0, len(m.sessions)) + selectedIdx := 0 + for i, item := range m.sessions { + title := strings.TrimSpace(item.Title) + if title == "" { + title = item.ID + } + entry := selectorItem{ + id: item.ID, + title: title, + } + items = append(items, entry) + if item.ID == selectedID { + selectedIdx = i + } + } + m.sessList.SetItems(items) + if len(items) > 0 { + m.sessList.Select(selectedIdx) + } +} + +func (m *TUIModel) currentBotID() string { + item, ok := m.botList.SelectedItem().(selectorItem) + if !ok { + return "" + } + return item.id +} + +func (m *TUIModel) currentSessionID() string { + item, ok := m.sessList.SelectedItem().(selectorItem) + if !ok { + return "" + } + return item.id +} + +func (m *TUIModel) appendTranscript(line string) { + if strings.TrimSpace(m.chatContent) == "" { + m.chatContent = line + } else { + m.chatContent += "\n\n---\n\n" + line + } + m.syncViewport(true) +} + +func (m *TUIModel) updateStreamPreview(msg conversation.UIMessage) { + if _, ok := m.streamPreviewItems[msg.ID]; !ok { + m.streamPreviewOrder = append(m.streamPreviewOrder, msg.ID) + } + m.streamPreviewItems[msg.ID] = msg + parts := make([]string, 0, len(m.streamPreviewOrder)) + for _, id := range m.streamPreviewOrder { + item, ok := m.streamPreviewItems[id] + if !ok { + continue + } + rendered := strings.TrimSpace(renderStreamPreviewMessage(item)) + if rendered == "" { + continue + } + parts = append(parts, rendered) + } + m.streamPreview = strings.Join(parts, "\n\n") +} + +func (m *TUIModel) clearStreamPreview() { + m.streamPreview = "" + m.streamPreviewOrder = nil + m.streamPreviewItems = map[int]conversation.UIMessage{} +} + +func renderStreamPreviewMessage(msg conversation.UIMessage) string { + switch msg.Type { + case conversation.UIMessageText: + return strings.TrimSpace(msg.Content) + case conversation.UIMessageReasoning: + content := strings.TrimSpace(msg.Content) + if content == "" { + return "" + } + return "[reasoning]\n" + content + case conversation.UIMessageTool: + state := "done" + if msg.Running != nil && *msg.Running { + state = "running" + } + return fmt.Sprintf("[tool:%s %s]", strings.TrimSpace(msg.Name), state) + case conversation.UIMessageAttachments: + return fmt.Sprintf("[attachments:%d]", len(msg.Attachments)) + default: + return strings.TrimSpace(msg.Content) + } +} + +func renderTurnMarkdown(turn conversation.UITurn) string { + header := "Assistant" + if strings.EqualFold(turn.Role, "user") { + header = "You" + } + if turn.SenderDisplayName != "" { + header = turn.SenderDisplayName + } + body := strings.TrimSpace(turn.Text) + if body == "" && len(turn.Messages) > 0 { + parts := make([]string, 0, len(turn.Messages)) + for _, msg := range turn.Messages { + parts = append(parts, RenderUIMessageMarkdown(msg)) + } + body = strings.Join(parts, "\n") + } + body = strings.TrimSpace(body) + if body == "" { + body = "_No content_" + } + return fmt.Sprintf("## %s\n\n%s", header, body) +} + +func RenderUIMessage(msg conversation.UIMessage) string { + return renderMarkdownToANSI(RenderUIMessageMarkdown(msg), 0) +} + +func RenderUIMessageMarkdown(msg conversation.UIMessage) string { + switch msg.Type { + case conversation.UIMessageText: + return strings.TrimSpace(msg.Content) + case conversation.UIMessageReasoning: + return fmt.Sprintf("> Reasoning\n>\n> %s", strings.ReplaceAll(strings.TrimSpace(msg.Content), "\n", "\n> ")) + case conversation.UIMessageTool: + state := "done" + if msg.Running != nil && *msg.Running { + state = "running" + } + return fmt.Sprintf("**Tool:** `%s` (%s)", strings.TrimSpace(msg.Name), state) + case conversation.UIMessageAttachments: + return fmt.Sprintf("**Attachments:** %d", len(msg.Attachments)) + default: + return strings.TrimSpace(msg.Content) + } +} + +func (m *TUIModel) syncViewport(gotoBottom bool) { + base := renderMarkdownToANSI(m.chatContent, m.viewport.Width) + preview := strings.TrimSpace(m.streamPreview) + content := "" + switch { + case base == "": + content = preview + case preview == "": + content = base + default: + content = base + "\n\n" + preview + } + m.viewportContent = content + m.viewport.SetContent(content) + if gotoBottom { + m.viewport.GotoBottom() + } +} + +func (m *TUIModel) renderChatViewport() string { + view := m.viewport.View() + lines := strings.Split(view, "\n") + if len(lines) == 0 { + lines = []string{""} + } + + height := max(1, m.viewport.Height) + if len(lines) < height { + padded := make([]string, height) + copy(padded, lines) + for i := len(lines); i < height; i++ { + padded[i] = "" + } + lines = padded + } else if len(lines) > height { + lines = lines[:height] + } + + totalLines := 0 + if strings.TrimSpace(m.viewportContent) != "" { + totalLines = len(strings.Split(m.viewportContent, "\n")) + } + if totalLines <= height { + return strings.Join(lines, "\n") + } + + thumbHeight := max(1, int(math.Round(float64(height*height)/float64(totalLines)))) + maxOffset := max(1, totalLines-height) + thumbTop := int(math.Round(float64(m.viewport.YOffset) / float64(maxOffset) * float64(height-thumbHeight))) + if thumbTop < 0 { + thumbTop = 0 + } + if thumbTop > height-thumbHeight { + thumbTop = height - thumbHeight + } + + railStyle := lipgloss.NewStyle().Foreground(mutedTitle) + thumbStyle := lipgloss.NewStyle().Foreground(memohPrimary).Bold(true) + withBar := make([]string, 0, len(lines)) + for i, line := range lines { + bar := railStyle.Render("│") + if i >= thumbTop && i < thumbTop+thumbHeight { + bar = thumbStyle.Render("█") + } + withBar = append(withBar, line+" "+bar) + } + return strings.Join(withBar, "\n") +} + +func renderMarkdownToANSI(markdown string, width int) string { + if strings.TrimSpace(markdown) == "" { + return "" + } + opts := []glamour.TermRendererOption{ + glamour.WithStandardStyle("dark"), + } + if width > 0 { + opts = append(opts, glamour.WithWordWrap(width)) + } + renderer, err := glamour.NewTermRenderer(opts...) + if err != nil { + return markdown + } + out, err := renderer.Render(markdown) + if err != nil { + return markdown + } + return strings.TrimRight(out, "\n") +} + +func nextFocus(current focusArea) focusArea { + return (current + 1) % 4 +} + +func prevFocus(current focusArea) focusArea { + if current == 0 { + return 3 + } + return current - 1 +} + +func panel(title, body string, focused bool, width int) string { + borderColor := mutedBorder + titleColor := mutedTitle + if focused { + borderColor = memohPrimary + titleColor = memohPrimary + } + + titleLine := lipgloss.NewStyle(). + Bold(focused). + Foreground(titleColor). + Render(title) + + style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(width). + Padding(0, 1) + return style.Render(titleLine + "\n" + body) +} + +func emptyFallback(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/mise.toml b/mise.toml index 4c1dd782..3d63457d 100644 --- a/mise.toml +++ b/mise.toml @@ -145,15 +145,10 @@ run = "scripts/release.sh --prepare-assets" depends = ["//:pnpm-install"] [tasks.build-unified] -description = "Build unified memoh binary" +description = "Build memoh CLI locally" depends = ["//:build-embedded-assets"] run = "go build -o bin/memoh ./cmd/memoh" -[tasks.release-binaries] -description = "Build release archive for one target (requires TARGET_OS TARGET_ARCH)" -depends = ["//:pnpm-install"] -run = "scripts/release.sh" - [tasks.install-socktainer] description = "Install socktainer" run = "brew tap socktainer/tap && brew install socktainer" diff --git a/scripts/db-drop.sh b/scripts/db-drop.sh index 25fd3588..f76361a6 100755 --- a/scripts/db-drop.sh +++ b/scripts/db-drop.sh @@ -11,4 +11,4 @@ if [ "$confirmation" != "yes" ]; then exit 0 fi -go run "${PROJECT_ROOT}/cmd/agent/main.go" migrate down +go run "${PROJECT_ROOT}/cmd/agent" migrate down diff --git a/scripts/db-up.sh b/scripts/db-up.sh index d7b32053..26a5a834 100755 --- a/scripts/db-up.sh +++ b/scripts/db-up.sh @@ -3,4 +3,4 @@ set -e PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -go run "${PROJECT_ROOT}/cmd/agent/main.go" migrate up +go run "${PROJECT_ROOT}/cmd/agent" migrate up diff --git a/scripts/release.sh b/scripts/release.sh index 50e93d4c..fa3ffb3e 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,4 +1,4 @@ -çç#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -23,8 +23,8 @@ Usage: scripts/release.sh [options] Options: --os Target OS (default: current GOOS) --arch Target ARCH (default: current GOARCH) - --version Version string injected into memoh binary - --commit-hash Commit hash injected into memoh binary + --version Version string injected into the memoh CLI + --commit-hash Commit hash injected into the memoh CLI --output-dir Output directory for release artifacts --prepare-assets Only prepare embedded assets, do not build archive EOF