From ab82a72639c8629cf2bb1b0304aafcfbff5b2bc1 Mon Sep 17 00:00:00 2001 From: Acbox Date: Wed, 11 Mar 2026 18:57:08 +0800 Subject: [PATCH] feat(command): extend slash command system with new commands and UX improvements Add 9 new command groups (/model, /memory, /search, /browser, /usage, /email, /heartbeat, /skill, /fs) and improve existing commands by hiding internal UUIDs, resolving IDs to human-readable names in /settings, and switching /schedule to name-based references. --- cmd/agent/main.go | 88 ++++++++ cmd/memoh/serve.go | 89 +++++++- internal/command/browser.go | 60 ++++++ internal/command/commands.go | 21 ++ internal/command/email_cmd.go | 95 +++++++++ internal/command/formatter.go | 77 +++++++ internal/command/fs.go | 62 ++++++ internal/command/handler.go | 209 +++++++++++++++++++ internal/command/handler_test.go | 335 ++++++++++++++++++++++++++++++ internal/command/heartbeat_cmd.go | 47 +++++ internal/command/inbox.go | 58 ++++++ internal/command/interfaces.go | 27 +++ internal/command/mcp.go | 96 +++++++++ internal/command/memory.go | 65 ++++++ internal/command/model.go | 107 ++++++++++ internal/command/parser.go | 98 +++++++++ internal/command/parser_test.go | 107 ++++++++++ internal/command/registry.go | 92 ++++++++ internal/command/schedule.go | 201 ++++++++++++++++++ internal/command/search.go | 61 ++++++ internal/command/settings.go | 204 ++++++++++++++++++ internal/command/skill.go | 31 +++ internal/command/subagent.go | 94 +++++++++ internal/command/usage.go | 144 +++++++++++++ 24 files changed, 2467 insertions(+), 1 deletion(-) create mode 100644 internal/command/browser.go create mode 100644 internal/command/commands.go create mode 100644 internal/command/email_cmd.go create mode 100644 internal/command/formatter.go create mode 100644 internal/command/fs.go create mode 100644 internal/command/handler.go create mode 100644 internal/command/handler_test.go create mode 100644 internal/command/heartbeat_cmd.go create mode 100644 internal/command/inbox.go create mode 100644 internal/command/interfaces.go create mode 100644 internal/command/mcp.go create mode 100644 internal/command/memory.go create mode 100644 internal/command/model.go create mode 100644 internal/command/parser.go create mode 100644 internal/command/parser_test.go create mode 100644 internal/command/registry.go create mode 100644 internal/command/schedule.go create mode 100644 internal/command/search.go create mode 100644 internal/command/settings.go create mode 100644 internal/command/skill.go create mode 100644 internal/command/subagent.go create mode 100644 internal/command/usage.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 8c88553a..82c67c51 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "os" + stdpath "path" "strings" "time" @@ -34,6 +35,7 @@ import ( "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/config" ctr "github.com/memohai/memoh/internal/containerd" "github.com/memohai/memoh/internal/conversation" @@ -423,6 +425,21 @@ func provideChannelRouter( bindService *bind.Service, mediaService *media.Service, inboxService *inbox.Service, + subagentService *subagent.Service, + scheduleService *schedule.Service, + settingsService *settings.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 *mcp.Manager, rc *boot.RuntimeConfig, ) *inbound.ChannelInboundProcessor { adapter, ok := registry.Get(qq.Type) @@ -440,6 +457,26 @@ func provideChannelRouter( processor.SetMediaService(mediaService) processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub)) processor.SetInboxService(inboxService) + processor.SetCommandHandler(command.NewHandler( + log, + &command.BotMemberRoleAdapter{BotService: botService}, + subagentService, + scheduleService, + settingsService, + mcpConnService, + inboxService, + modelsService, + providersService, + memProvService, + searchProvService, + browserCtxService, + emailService, + emailOutboxService, + heartbeatService, + queries, + &commandSkillLoaderAdapter{handler: containerdHandler}, + &commandContainerFSAdapter{manager: manager}, + )) return processor } @@ -928,3 +965,54 @@ 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 +} + +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 mcp.Manager to command.ContainerFS. +type commandContainerFSAdapter struct { + manager *mcp.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.ListDir(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/memoh/serve.go b/cmd/memoh/serve.go index abecf949..5c6da830 100644 --- a/cmd/memoh/serve.go +++ b/cmd/memoh/serve.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "os" + stdpath "path" "strings" "time" @@ -34,6 +35,7 @@ import ( "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/config" ctr "github.com/memohai/memoh/internal/containerd" "github.com/memohai/memoh/internal/conversation" @@ -46,8 +48,10 @@ import ( emailmailgun "github.com/memohai/memoh/internal/email/adapters/mailgun" "github.com/memohai/memoh/internal/handlers" "github.com/memohai/memoh/internal/healthcheck" + "github.com/memohai/memoh/internal/browsercontexts" channelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/channel" mcpchecker "github.com/memohai/memoh/internal/healthcheck/checkers/mcp" + "github.com/memohai/memoh/internal/heartbeat" "github.com/memohai/memoh/internal/inbox" "github.com/memohai/memoh/internal/logger" "github.com/memohai/memoh/internal/mcp" @@ -122,8 +126,11 @@ func runServe() { provideChannelManager, provideChannelLifecycleService, provideChatResolver, + browsercontexts.NewService, provideScheduleTriggerer, schedule.NewService, + provideHeartbeatTriggerer, + heartbeat.NewService, provideContainerdHandler, provideFederationGateway, provideToolGatewayService, @@ -162,6 +169,7 @@ func runServe() { fx.Invoke( startMemoryProviderBootstrap, startScheduleService, + startHeartbeatService, startChannelManager, startEmailManager, startContainerReconciliation, @@ -267,6 +275,10 @@ func provideScheduleTriggerer(resolver *flow.Resolver) schedule.Triggerer { return flow.NewScheduleGateway(resolver) } +func provideHeartbeatTriggerer(resolver *flow.Resolver) heartbeat.Triggerer { + return flow.NewHeartbeatGateway(resolver) +} + func provideChatResolver(log *slog.Logger, cfg config.Config, modelsService *models.Service, queries *dbsqlc.Queries, chatService *conversation.Service, msgService *message.DBService, settingsService *settings.Service, mediaService *media.Service, containerdHandler *handlers.ContainerdHandler, inboxService *inbox.Service, memoryRegistry *memprovider.Registry) *flow.Resolver { resolver := flow.NewResolver(log, modelsService, queries, chatService, msgService, settingsService, cfg.AgentGateway.BaseURL(), 120*time.Second) resolver.SetMemoryRegistry(memoryRegistry) @@ -292,11 +304,31 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService return registry } -func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *local.RouteHub, routeService *route.DBService, msgService *message.DBService, resolver *flow.Resolver, identityService *identities.Service, botService *bots.Service, policyService *policy.Service, preauthService *preauth.Service, bindService *bind.Service, mediaService *media.Service, inboxService *inbox.Service, rc *boot.RuntimeConfig) *inbound.ChannelInboundProcessor { +func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *local.RouteHub, routeService *route.DBService, msgService *message.DBService, resolver *flow.Resolver, identityService *identities.Service, botService *bots.Service, policyService *policy.Service, preauthService *preauth.Service, bindService *bind.Service, mediaService *media.Service, inboxService *inbox.Service, subagentService *subagent.Service, scheduleService *schedule.Service, settingsService *settings.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 *mcp.Manager, rc *boot.RuntimeConfig) *inbound.ChannelInboundProcessor { processor := inbound.NewChannelInboundProcessor(log, registry, routeService, msgService, resolver, identityService, botService, policyService, preauthService, bindService, rc.JwtSecret, 5*time.Minute) processor.SetMediaService(mediaService) processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub)) processor.SetInboxService(inboxService) + processor.SetCommandHandler(command.NewHandler( + log, + &command.BotMemberRoleAdapter{BotService: botService}, + subagentService, + scheduleService, + settingsService, + mcpConnService, + inboxService, + modelsService, + providersService, + memProvService, + searchProvService, + browserCtxService, + emailService, + emailOutboxService, + heartbeatService, + queries, + &commandSkillLoaderAdapter{handler: containerdHandler}, + &commandContainerFSAdapter{manager: manager}, + )) return processor } @@ -515,6 +547,10 @@ 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{ @@ -818,3 +854,54 @@ 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 +} + +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 mcp.Manager to command.ContainerFS. +type commandContainerFSAdapter struct { + manager *mcp.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.ListDir(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/internal/command/browser.go b/internal/command/browser.go new file mode 100644 index 00000000..d5de3a4d --- /dev/null +++ b/internal/command/browser.go @@ -0,0 +1,60 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/settings" +) + +func (h *Handler) buildBrowserGroup() *CommandGroup { + g := newCommandGroup("browser", "Manage browser context") + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all browser contexts", + Handler: func(cc CommandContext) (string, error) { + items, err := h.browserCtxService.List(cc.Ctx) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No browser contexts found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + records = append(records, []kv{ + {"Name", item.Name}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "set", + Usage: "set - Set the browser context for this bot", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /browser set ", nil + } + name := cc.Args[0] + items, err := h.browserCtxService.List(cc.Ctx) + if err != nil { + return "", err + } + for _, item := range items { + if strings.EqualFold(item.Name, name) { + _, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{ + BrowserContextID: item.ID, + }) + if err != nil { + return "", err + } + return fmt.Sprintf("Browser context set to %q.", item.Name), nil + } + } + return fmt.Sprintf("Browser context %q not found.", name), nil + }, + }) + return g +} diff --git a/internal/command/commands.go b/internal/command/commands.go new file mode 100644 index 00000000..1b7db067 --- /dev/null +++ b/internal/command/commands.go @@ -0,0 +1,21 @@ +package command + +// buildRegistry constructs the full command registry with all resource groups. +func (h *Handler) buildRegistry() *Registry { + r := newRegistry() + r.RegisterGroup(h.buildSubagentGroup()) + r.RegisterGroup(h.buildScheduleGroup()) + r.RegisterGroup(h.buildMCPGroup()) + r.RegisterGroup(h.buildInboxGroup()) + r.RegisterGroup(h.buildSettingsGroup()) + r.RegisterGroup(h.buildModelGroup()) + r.RegisterGroup(h.buildMemoryGroup()) + r.RegisterGroup(h.buildSearchGroup()) + r.RegisterGroup(h.buildBrowserGroup()) + r.RegisterGroup(h.buildUsageGroup()) + r.RegisterGroup(h.buildEmailGroup()) + r.RegisterGroup(h.buildHeartbeatGroup()) + r.RegisterGroup(h.buildSkillGroup()) + r.RegisterGroup(h.buildFSGroup()) + return r +} diff --git a/internal/command/email_cmd.go b/internal/command/email_cmd.go new file mode 100644 index 00000000..4d9fc6e3 --- /dev/null +++ b/internal/command/email_cmd.go @@ -0,0 +1,95 @@ +package command + +import ( + "fmt" + "strings" +) + +func (h *Handler) buildEmailGroup() *CommandGroup { + g := newCommandGroup("email", "View email configuration") + g.Register(SubCommand{ + Name: "providers", + Usage: "providers - List email providers", + Handler: func(cc CommandContext) (string, error) { + items, err := h.emailService.ListProviders(cc.Ctx, "") + if err != nil { + return "", err + } + if len(items) == 0 { + return "No email providers found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + records = append(records, []kv{ + {"Name", item.Name}, + {"Provider", item.Provider}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "bindings", + Usage: "bindings - List bot email bindings", + Handler: func(cc CommandContext) (string, error) { + items, err := h.emailService.ListBindings(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No email bindings found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + perms := buildPermString(item.CanRead, item.CanWrite, item.CanDelete) + records = append(records, []kv{ + {"Address", item.EmailAddress}, + {"Permissions", perms}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "outbox", + Usage: "outbox - List recently sent emails", + Handler: func(cc CommandContext) (string, error) { + items, _, err := h.emailOutboxService.ListByBot(cc.Ctx, cc.BotID, 10, 0) + if err != nil { + return "", err + } + if len(items) == 0 { + return "Outbox is empty.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + to := strings.Join(item.To, ", ") + records = append(records, []kv{ + {"Subject", truncate(item.Subject, 40)}, + {"To", truncate(to, 40)}, + {"Status", item.Status}, + {"Sent", item.SentAt.Format("01-02 15:04")}, + }) + } + return formatItems(records), nil + }, + }) + return g +} + +func buildPermString(read, write, del bool) string { + var parts []string + if read { + parts = append(parts, "read") + } + if write { + parts = append(parts, "write") + } + if del { + parts = append(parts, "delete") + } + if len(parts) == 0 { + return "none" + } + return fmt.Sprintf("%s", strings.Join(parts, ", ")) +} diff --git a/internal/command/formatter.go b/internal/command/formatter.go new file mode 100644 index 00000000..43d3f211 --- /dev/null +++ b/internal/command/formatter.go @@ -0,0 +1,77 @@ +package command + +import ( + "fmt" + "strings" +) + +// formatItems renders a list of records as a Markdown-style list. +// Each record is a slice of kv pairs; the first pair's value is used as the +// bullet title, and subsequent pairs are indented beneath it. +// +// Example output: +// +// - mybot +// Description: A helpful assistant +// ID: abc123 +// +// - another +// Description: Something else +// ID: def456 +func formatItems(items [][]kv) string { + if len(items) == 0 { + return "" + } + var b strings.Builder + for i, record := range items { + if len(record) == 0 { + continue + } + if i > 0 { + b.WriteByte('\n') + } + fmt.Fprintf(&b, "- %s\n", record[0].value) + for _, pair := range record[1:] { + fmt.Fprintf(&b, " %s: %s\n", pair.key, pair.value) + } + } + return b.String() +} + +// formatKV renders key-value pairs as a simple Markdown list. +// +// Example output: +// +// - ID: abc123 +// - Name: mybot +func formatKV(pairs []kv) string { + var b strings.Builder + for _, p := range pairs { + fmt.Fprintf(&b, "- %s: %s\n", p.key, p.value) + } + return b.String() +} + +type kv struct { + key string + value string +} + +// truncate shortens a string to maxLen, appending "..." if truncated. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// boolStr returns "yes" or "no". +func boolStr(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/internal/command/fs.go b/internal/command/fs.go new file mode 100644 index 00000000..e67fe466 --- /dev/null +++ b/internal/command/fs.go @@ -0,0 +1,62 @@ +package command + +import ( + "fmt" + "strings" +) + +func (h *Handler) buildFSGroup() *CommandGroup { + g := newCommandGroup("fs", "Browse container filesystem") + g.Register(SubCommand{ + Name: "list", + Usage: "list [path] - List files in the container", + Handler: func(cc CommandContext) (string, error) { + if h.containerFS == nil { + return "Container filesystem is not available.", nil + } + dir := "/" + if len(cc.Args) > 0 { + dir = cc.Args[0] + } + entries, err := h.containerFS.ListDir(cc.Ctx, cc.BotID, dir) + if err != nil { + return "", err + } + if len(entries) == 0 { + return fmt.Sprintf("Directory %q is empty.", dir), nil + } + var b strings.Builder + fmt.Fprintf(&b, "%s:\n", dir) + for _, e := range entries { + if e.IsDir { + fmt.Fprintf(&b, " %s/\n", e.Name) + } else { + fmt.Fprintf(&b, " %s (%d bytes)\n", e.Name, e.Size) + } + } + return b.String(), nil + }, + }) + g.Register(SubCommand{ + Name: "read", + Usage: "read - Read a file from the container", + Handler: func(cc CommandContext) (string, error) { + if h.containerFS == nil { + return "Container filesystem is not available.", nil + } + if len(cc.Args) < 1 { + return "Usage: /fs read ", nil + } + content, err := h.containerFS.ReadFile(cc.Ctx, cc.BotID, cc.Args[0]) + if err != nil { + return "", err + } + const maxLen = 2000 + if len(content) > maxLen { + content = content[:maxLen] + "\n... (truncated)" + } + return fmt.Sprintf("```\n%s\n```", content), nil + }, + }) + return g +} diff --git a/internal/command/handler.go b/internal/command/handler.go new file mode 100644 index 00000000..52ecbb0d --- /dev/null +++ b/internal/command/handler.go @@ -0,0 +1,209 @@ +package command + +import ( + "context" + "fmt" + "log/slog" + + "github.com/memohai/memoh/internal/bots" + "github.com/memohai/memoh/internal/browsercontexts" + dbsqlc "github.com/memohai/memoh/internal/db/sqlc" + emailpkg "github.com/memohai/memoh/internal/email" + "github.com/memohai/memoh/internal/heartbeat" + "github.com/memohai/memoh/internal/inbox" + "github.com/memohai/memoh/internal/mcp" + memprovider "github.com/memohai/memoh/internal/memory/provider" + "github.com/memohai/memoh/internal/models" + "github.com/memohai/memoh/internal/providers" + "github.com/memohai/memoh/internal/schedule" + "github.com/memohai/memoh/internal/searchproviders" + "github.com/memohai/memoh/internal/settings" + "github.com/memohai/memoh/internal/subagent" +) + +// MemberRoleResolver resolves a user's role within a bot. +type MemberRoleResolver interface { + GetMemberRole(ctx context.Context, botID, channelIdentityID string) (string, error) +} + +// BotMemberRoleAdapter adapts bots.Service to MemberRoleResolver. +type BotMemberRoleAdapter struct { + BotService *bots.Service +} + +func (a *BotMemberRoleAdapter) GetMemberRole(ctx context.Context, botID, channelIdentityID string) (string, error) { + member, err := a.BotService.GetMember(ctx, botID, channelIdentityID) + if err != nil { + return "", err + } + return member.Role, nil +} + +// Handler processes slash commands intercepted before they reach the LLM. +type Handler struct { + registry *Registry + roleResolver MemberRoleResolver + subagentService *subagent.Service + scheduleService *schedule.Service + settingsService *settings.Service + mcpConnService *mcp.ConnectionService + inboxService *inbox.Service + + 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 + skillLoader SkillLoader + containerFS ContainerFS + + logger *slog.Logger +} + +// NewHandler creates a Handler with all required services. +func NewHandler( + log *slog.Logger, + roleResolver MemberRoleResolver, + subagentService *subagent.Service, + scheduleService *schedule.Service, + settingsService *settings.Service, + mcpConnService *mcp.ConnectionService, + inboxService *inbox.Service, + 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, + skillLoader SkillLoader, + containerFS ContainerFS, +) *Handler { + if log == nil { + log = slog.Default() + } + h := &Handler{ + roleResolver: roleResolver, + subagentService: subagentService, + scheduleService: scheduleService, + settingsService: settingsService, + mcpConnService: mcpConnService, + inboxService: inboxService, + modelsService: modelsService, + providersService: providersService, + memProvService: memProvService, + searchProvService: searchProvService, + browserCtxService: browserCtxService, + emailService: emailService, + emailOutboxService: emailOutboxService, + heartbeatService: heartbeatService, + queries: queries, + skillLoader: skillLoader, + containerFS: containerFS, + logger: log.With(slog.String("component", "command")), + } + h.registry = h.buildRegistry() + return h +} + +// IsCommand reports whether the text contains a slash command. +// Handles both direct commands ("/help") and mention-prefixed commands ("@bot /help"). +func (h *Handler) IsCommand(text string) bool { + cmdText := ExtractCommandText(text) + if cmdText == "" || len(cmdText) < 2 { + return false + } + // Validate that it refers to a known command, not arbitrary "/path/to/file". + parsed, err := Parse(cmdText) + if err != nil { + return false + } + if parsed.Resource == "help" { + return true + } + _, ok := h.registry.groups[parsed.Resource] + return ok +} + +// Execute parses and runs a slash command, returning the text reply. +func (h *Handler) Execute(ctx context.Context, botID, channelIdentityID, text string) (string, error) { + cmdText := ExtractCommandText(text) + if cmdText == "" { + return h.registry.GlobalHelp(), nil + } + parsed, err := Parse(cmdText) + if err != nil { + return h.registry.GlobalHelp(), nil + } + + // Resolve the user's role in this bot. + role := "" + if h.roleResolver != nil && channelIdentityID != "" { + r, err := h.roleResolver.GetMemberRole(ctx, botID, channelIdentityID) + if err != nil { + h.logger.Warn("failed to resolve member role", + slog.String("bot_id", botID), + slog.String("channel_identity_id", channelIdentityID), + slog.Any("error", err), + ) + } else { + role = r + } + } + + cc := CommandContext{ + Ctx: ctx, + BotID: botID, + Role: role, + Args: parsed.Args, + } + + // /help + if parsed.Resource == "help" { + return h.registry.GlobalHelp(), nil + } + + group, ok := h.registry.groups[parsed.Resource] + if !ok { + return fmt.Sprintf("Unknown command: /%s\n\n%s", parsed.Resource, h.registry.GlobalHelp()), nil + } + + if parsed.Action == "" { + if group.DefaultAction != "" { + parsed.Action = group.DefaultAction + } else { + return group.Usage(), nil + } + } + + sub, ok := group.commands[parsed.Action] + if !ok { + return fmt.Sprintf("Unknown action \"%s\" for /%s.\n\n%s", parsed.Action, parsed.Resource, group.Usage()), nil + } + + if sub.IsWrite && role != bots.MemberRoleOwner { + return "Permission denied: only the bot owner can execute this command.", nil + } + + result, handlerErr := safeExecute(sub.Handler, cc) + if handlerErr != nil { + return fmt.Sprintf("Error: %s", handlerErr.Error()), nil + } + return result, nil +} + +// safeExecute runs a sub-command handler and recovers from panics. +func safeExecute(fn func(CommandContext) (string, error), cc CommandContext) (result string, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("internal error: %v", r) + } + }() + return fn(cc) +} diff --git a/internal/command/handler_test.go b/internal/command/handler_test.go new file mode 100644 index 00000000..4f09c6be --- /dev/null +++ b/internal/command/handler_test.go @@ -0,0 +1,335 @@ +package command + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/memohai/memoh/internal/bots" + "github.com/memohai/memoh/internal/inbox" + "github.com/memohai/memoh/internal/mcp" + "github.com/memohai/memoh/internal/schedule" + "github.com/memohai/memoh/internal/settings" + "github.com/memohai/memoh/internal/subagent" +) + +// --- fake services --- + +type fakeRoleResolver struct { + role string + err error +} + +func (f *fakeRoleResolver) GetMemberRole(_ context.Context, _, _ string) (string, error) { + return f.role, f.err +} + +type fakeSubagentService struct { + items []subagent.Subagent +} + +func (f *fakeSubagentService) list() []subagent.Subagent { + return f.items +} + +type fakeScheduleService struct { + items []schedule.Schedule +} + +type fakeInboxService struct { + count inbox.CountResult +} + +// newTestHandler creates a Handler with nil services for use in tests. +func newTestHandler(roleResolver MemberRoleResolver) *Handler { + return NewHandler(nil, roleResolver, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) +} + +// --- tests --- + +func TestIsCommand(t *testing.T) { + t.Parallel() + h := newTestHandler(nil) + tests := []struct { + input string + want bool + }{ + {"/help", true}, + {"/subagent list", true}, + {" /schedule list", true}, + {"@BotName /help", true}, + {"@_user_1 /schedule list", true}, + {"<@123456> /mcp list", true}, + {"/help@MemohBot", true}, + {"hello", false}, + {"", false}, + {"/", false}, + {"/ ", false}, + {"/unknown_cmd", false}, + {"check https://example.com/help", false}, + {"@bot hello", false}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + if got := h.IsCommand(tt.input); got != tt.want { + t.Errorf("IsCommand(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestExecute_Help(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Available commands") { + t.Errorf("expected help text, got: %s", result) + } + if !strings.Contains(result, "/subagent") { + t.Errorf("expected /subagent in help, got: %s", result) + } +} + +func TestExecute_UnknownCommand(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "/foobar") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Unknown command") { + t.Errorf("expected unknown command message, got: %s", result) + } +} + +func TestExecute_WithMentionPrefix(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "@BotName /help") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Available commands") { + t.Errorf("expected help text from mention-prefixed command, got: %s", result) + } +} + +func TestExecute_TelegramBotSuffix(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help@MemohBot") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Available commands") { + t.Errorf("expected help text from telegram-style command, got: %s", result) + } +} + +func TestExecute_UnknownAction(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "/subagent foobar") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Unknown action") { + t.Errorf("expected unknown action message, got: %s", result) + } + if !strings.Contains(result, "/subagent") { + t.Errorf("expected subagent usage in message, got: %s", result) + } +} + +func TestExecute_WritePermissionDenied(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleMember}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "/subagent create test desc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Permission denied") { + t.Errorf("expected permission denied, got: %s", result) + } +} + +func TestExecute_WritePermissionAllowedForOwner(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "/subagent create") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(result, "Permission denied") { + t.Errorf("owner should not get permission denied, got: %s", result) + } + if !strings.Contains(result, "Usage:") { + t.Errorf("expected usage hint for missing args, got: %s", result) + } +} + +func TestExecute_SettingsDefaultAction(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleMember}) + result, err := h.Execute(context.Background(), "bot-1", "user-1", "/settings") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(result, "Unknown action") { + t.Errorf("expected settings get attempt, not unknown action, got: %s", result) + } +} + +func TestExecute_MissingArgs(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + tests := []struct { + cmd string + contains string + }{ + {"/subagent get", "Usage:"}, + {"/subagent create", "Usage:"}, + {"/subagent delete", "Usage:"}, + {"/schedule get", "Usage:"}, + {"/schedule create", "Usage:"}, + {"/schedule delete", "Usage:"}, + {"/mcp get", "Usage:"}, + {"/mcp delete", "Usage:"}, + {"/fs read", "not available"}, + {"/model set", "Usage:"}, + {"/model set-heartbeat", "Usage:"}, + {"/memory set", "Usage:"}, + {"/search set", "Usage:"}, + {"/browser set", "Usage:"}, + } + for _, tt := range tests { + t.Run(tt.cmd, func(t *testing.T) { + t.Parallel() + result, err := h.Execute(context.Background(), "bot-1", "user-1", tt.cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, tt.contains) { + t.Errorf("expected %q in result, got: %s", tt.contains, result) + } + }) + } +} + +func TestFormatItems(t *testing.T) { + t.Parallel() + result := formatItems([][]kv{ + {{"Name", "foo"}, {"Type", "bar"}}, + {{"Name", "longname"}, {"Type", "x"}}, + }) + if !strings.Contains(result, "- foo") { + t.Errorf("expected '- foo' bullet, got: %s", result) + } + if !strings.Contains(result, " Type: bar") { + t.Errorf("expected indented 'Type: bar', got: %s", result) + } + if !strings.Contains(result, "- longname") { + t.Errorf("expected '- longname' bullet, got: %s", result) + } +} + +func TestFormatItems_Empty(t *testing.T) { + t.Parallel() + result := formatItems(nil) + if result != "" { + t.Errorf("expected empty string for nil items, got: %q", result) + } +} + +func TestFormatKV(t *testing.T) { + t.Parallel() + result := formatKV([]kv{ + {"Name", "test"}, + {"ID", "123"}, + }) + if !strings.Contains(result, "- Name: test") { + t.Errorf("expected '- Name: test', got: %s", result) + } + if !strings.Contains(result, "- ID: 123") { + t.Errorf("expected '- ID: 123', got: %s", result) + } +} + +func TestTruncate(t *testing.T) { + t.Parallel() + if got := truncate("hello world", 5); got != "he..." { + t.Errorf("truncate: got %q", got) + } + if got := truncate("hi", 5); got != "hi" { + t.Errorf("truncate short: got %q", got) + } +} + +// Verify that the global help includes all resource groups. +func TestGlobalHelp_AllGroups(t *testing.T) { + t.Parallel() + h := newTestHandler(nil) + help := h.registry.GlobalHelp() + for _, group := range []string{ + "subagent", "schedule", "mcp", "inbox", "settings", + "model", "memory", "search", "browser", "usage", + "email", "heartbeat", "skill", "fs", + } { + if !strings.Contains(help, "/"+group) { + t.Errorf("missing /%s in global help", group) + } + } +} + +// Verify write commands are tagged with [owner] in usage. +func TestUsage_OwnerTag(t *testing.T) { + t.Parallel() + h := newTestHandler(nil) + for _, name := range h.registry.order { + group := h.registry.groups[name] + usage := group.Usage() + for _, subName := range group.order { + sub := group.commands[subName] + if sub.IsWrite && !strings.Contains(usage, "[owner]") { + t.Errorf("/%s %s is a write command but usage missing [owner] tag", name, subName) + } + } + } +} + +// Verify new commands with nil services return graceful errors, not panics. +func TestNewCommands_NilServices(t *testing.T) { + t.Parallel() + h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner}) + cmds := []string{ + "/skill list", + "/fs list", + "/fs read /test.txt", + } + for _, cmd := range cmds { + t.Run(cmd, func(t *testing.T) { + t.Parallel() + result, err := h.Execute(context.Background(), "bot-1", "user-1", cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty result") + } + }) + } +} + +// suppress unused warnings +var ( + _ = fakeSubagentService{items: []subagent.Subagent{{ID: "1", Name: "test", CreatedAt: time.Now(), UpdatedAt: time.Now()}}} + _ = fakeScheduleService{items: []schedule.Schedule{{ID: "1", Name: "test"}}} + _ = fakeInboxService{count: inbox.CountResult{Unread: 1, Total: 2}} + _ = mcp.Connection{} + _ = settings.Settings{} +) diff --git a/internal/command/heartbeat_cmd.go b/internal/command/heartbeat_cmd.go new file mode 100644 index 00000000..5628a6a2 --- /dev/null +++ b/internal/command/heartbeat_cmd.go @@ -0,0 +1,47 @@ +package command + +import ( + "fmt" +) + +func (h *Handler) buildHeartbeatGroup() *CommandGroup { + g := newCommandGroup("heartbeat", "View heartbeat logs") + g.DefaultAction = "logs" + g.Register(SubCommand{ + Name: "logs", + Usage: "logs - List recent heartbeat logs", + Handler: func(cc CommandContext) (string, error) { + items, err := h.heartbeatService.ListLogs(cc.Ctx, cc.BotID, nil, 10) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No heartbeat logs found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + dur := "" + if item.CompletedAt != nil { + dur = fmt.Sprintf("%.1fs", item.CompletedAt.Sub(item.StartedAt).Seconds()) + } + errMsg := "" + if item.ErrorMessage != "" { + errMsg = truncate(item.ErrorMessage, 50) + } + rec := []kv{ + {"Time", item.StartedAt.Format("01-02 15:04:05")}, + {"Status", item.Status}, + } + if dur != "" { + rec = append(rec, kv{"Duration", dur}) + } + if errMsg != "" { + rec = append(rec, kv{"Error", errMsg}) + } + records = append(records, rec) + } + return formatItems(records), nil + }, + }) + return g +} diff --git a/internal/command/inbox.go b/internal/command/inbox.go new file mode 100644 index 00000000..5f03488e --- /dev/null +++ b/internal/command/inbox.go @@ -0,0 +1,58 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/inbox" +) + +func (h *Handler) buildInboxGroup() *CommandGroup { + g := newCommandGroup("inbox", "View bot inbox") + g.Register(SubCommand{ + Name: "list", + Usage: "list [--unread] - List inbox items", + Handler: func(cc CommandContext) (string, error) { + filter := inbox.ListFilter{Limit: 20} + for _, arg := range cc.Args { + if strings.EqualFold(arg, "--unread") { + unread := false + filter.IsRead = &unread + } + } + items, err := h.inboxService.List(cc.Ctx, cc.BotID, filter) + if err != nil { + return "", err + } + if len(items) == 0 { + return "Inbox is empty.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + status := "unread" + if item.IsRead { + status = "read" + } + records = append(records, []kv{ + {"Source", item.Source}, + {"Content", truncate(item.Content, 50)}, + {"Status", status}, + {"Time", item.CreatedAt.Format("01-02 15:04")}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "count", + Usage: "count - Show inbox counts", + Handler: func(cc CommandContext) (string, error) { + result, err := h.inboxService.Count(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + return fmt.Sprintf("Unread: %d / Total: %d", result.Unread, result.Total), nil + }, + }) + return g +} diff --git a/internal/command/interfaces.go b/internal/command/interfaces.go new file mode 100644 index 00000000..3f3db905 --- /dev/null +++ b/internal/command/interfaces.go @@ -0,0 +1,27 @@ +package command + +import "context" + +// Skill represents a single skill loaded from a bot's container. +type Skill struct { + Name string + Description string +} + +// SkillLoader loads skills for a bot. +type SkillLoader interface { + LoadSkills(ctx context.Context, botID string) ([]Skill, error) +} + +// FSEntry represents a file or directory in a container filesystem. +type FSEntry struct { + Name string + IsDir bool + Size int64 +} + +// ContainerFS provides read-only access to a bot's container filesystem. +type ContainerFS interface { + ListDir(ctx context.Context, botID, path string) ([]FSEntry, error) + ReadFile(ctx context.Context, botID, path string) (string, error) +} diff --git a/internal/command/mcp.go b/internal/command/mcp.go new file mode 100644 index 00000000..dad32c40 --- /dev/null +++ b/internal/command/mcp.go @@ -0,0 +1,96 @@ +package command + +import ( + "fmt" + "strings" +) + +func (h *Handler) buildMCPGroup() *CommandGroup { + g := newCommandGroup("mcp", "Manage MCP connections") + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all MCP connections", + Handler: func(cc CommandContext) (string, error) { + items, err := h.mcpConnService.ListByBot(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No MCP connections found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + records = append(records, []kv{ + {"Name", item.Name}, + {"Type", item.Type}, + {"Active", boolStr(item.Active)}, + {"Status", item.Status}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "get", + Usage: "get - Get MCP connection details", + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /mcp get ", nil + } + name := cc.Args[0] + items, err := h.mcpConnService.ListByBot(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + for _, item := range items { + if strings.EqualFold(item.Name, name) { + toolNames := make([]string, 0, len(item.ToolsCache)) + for _, t := range item.ToolsCache { + toolNames = append(toolNames, t.Name) + } + toolsStr := "none" + if len(toolNames) > 0 { + toolsStr = strings.Join(toolNames, ", ") + } + return formatKV([]kv{ + {"Name", item.Name}, + {"Type", item.Type}, + {"Active", boolStr(item.Active)}, + {"Status", item.Status}, + {"Status Message", item.StatusMessage}, + {"Auth Type", item.AuthType}, + {"Tools", toolsStr}, + {"Created", item.CreatedAt.Format("2006-01-02 15:04:05")}, + {"Updated", item.UpdatedAt.Format("2006-01-02 15:04:05")}, + }), nil + } + } + return fmt.Sprintf("MCP connection %q not found.", name), nil + }, + }) + g.Register(SubCommand{ + Name: "delete", + Usage: "delete - Delete an MCP connection", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /mcp delete ", nil + } + name := cc.Args[0] + items, err := h.mcpConnService.ListByBot(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + for _, item := range items { + if strings.EqualFold(item.Name, name) { + if err := h.mcpConnService.Delete(cc.Ctx, cc.BotID, item.ID); err != nil { + return "", err + } + return fmt.Sprintf("MCP connection %q deleted.", name), nil + } + } + return fmt.Sprintf("MCP connection %q not found.", name), nil + }, + }) + return g +} diff --git a/internal/command/memory.go b/internal/command/memory.go new file mode 100644 index 00000000..fce3d0a1 --- /dev/null +++ b/internal/command/memory.go @@ -0,0 +1,65 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/settings" +) + +func (h *Handler) buildMemoryGroup() *CommandGroup { + g := newCommandGroup("memory", "Manage memory provider") + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all memory providers", + Handler: func(cc CommandContext) (string, error) { + items, err := h.memProvService.List(cc.Ctx) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No memory providers found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + def := "" + if item.IsDefault { + def = " (default)" + } + records = append(records, []kv{ + {"Name", item.Name + def}, + {"Provider", item.Provider}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "set", + Usage: "set - Set the memory provider for this bot", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /memory set ", nil + } + name := cc.Args[0] + items, err := h.memProvService.List(cc.Ctx) + if err != nil { + return "", err + } + for _, item := range items { + if strings.EqualFold(item.Name, name) { + _, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{ + MemoryProviderID: item.ID, + }) + if err != nil { + return "", err + } + return fmt.Sprintf("Memory provider set to %q.", item.Name), nil + } + } + return fmt.Sprintf("Memory provider %q not found.", name), nil + }, + }) + return g +} diff --git a/internal/command/model.go b/internal/command/model.go new file mode 100644 index 00000000..6870102a --- /dev/null +++ b/internal/command/model.go @@ -0,0 +1,107 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/models" + "github.com/memohai/memoh/internal/settings" +) + +func (h *Handler) buildModelGroup() *CommandGroup { + g := newCommandGroup("model", "Manage bot models") + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all available chat models", + Handler: func(cc CommandContext) (string, error) { + items, err := h.modelsService.ListByType(cc.Ctx, models.ModelTypeChat) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No chat models found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + provName := h.resolveProviderName(cc, item.LlmProviderID) + records = append(records, []kv{ + {"Model", item.Name}, + {"Provider", provName}, + {"Model ID", item.ModelID}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "set", + Usage: "set - Set the chat model", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 2 { + return "Usage: /model set ", nil + } + modelResp, err := h.findModelByProviderAndName(cc, cc.Args[0], cc.Args[1]) + if err != nil { + return "", err + } + _, err = h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{ + ChatModelID: modelResp.ID, + }) + if err != nil { + return "", err + } + return fmt.Sprintf("Chat model set to %s (%s).", modelResp.Name, cc.Args[0]), nil + }, + }) + g.Register(SubCommand{ + Name: "set-heartbeat", + Usage: "set-heartbeat - Set the heartbeat model", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 2 { + return "Usage: /model set-heartbeat ", nil + } + modelResp, err := h.findModelByProviderAndName(cc, cc.Args[0], cc.Args[1]) + if err != nil { + return "", err + } + _, err = h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{ + HeartbeatModelID: modelResp.ID, + }) + if err != nil { + return "", err + } + return fmt.Sprintf("Heartbeat model set to %s (%s).", modelResp.Name, cc.Args[0]), nil + }, + }) + return g +} + +func (h *Handler) resolveProviderName(cc CommandContext, providerID string) string { + if h.providersService == nil || providerID == "" { + return providerID + } + p, err := h.providersService.Get(cc.Ctx, providerID) + if err != nil { + return providerID + } + return p.Name +} + +func (h *Handler) findModelByProviderAndName(cc CommandContext, providerName, modelName string) (models.GetResponse, error) { + provider, err := h.providersService.GetByName(cc.Ctx, providerName) + if err != nil { + return models.GetResponse{}, fmt.Errorf("provider %q not found", providerName) + } + chatModels, err := h.modelsService.ListByProviderIDAndType(cc.Ctx, provider.ID, models.ModelTypeChat) + if err != nil { + return models.GetResponse{}, err + } + for _, m := range chatModels { + if strings.EqualFold(m.Name, modelName) || strings.EqualFold(m.ModelID, modelName) { + return m, nil + } + } + return models.GetResponse{}, fmt.Errorf("model %q not found under provider %q", modelName, providerName) +} diff --git a/internal/command/parser.go b/internal/command/parser.go new file mode 100644 index 00000000..a9642077 --- /dev/null +++ b/internal/command/parser.go @@ -0,0 +1,98 @@ +package command + +import ( + "errors" + "strings" +) + +// ParsedCommand holds the parsed components of a slash command. +type ParsedCommand struct { + Resource string // e.g. "schedule", "subagent", "help" + Action string // e.g. "list", "get", "create" + Args []string // remaining positional arguments +} + +// Parse parses a raw command string into its components. +// Expected format: /resource [action] [args...] +func Parse(text string) (ParsedCommand, error) { + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, "/") { + return ParsedCommand{}, errors.New("command must start with /") + } + text = text[1:] // strip leading / + + tokens := tokenize(text) + if len(tokens) == 0 { + return ParsedCommand{}, errors.New("empty command") + } + + resource := strings.ToLower(tokens[0]) + // Strip Telegram-style @botname suffix (e.g. "help@MemohBot" -> "help"). + if idx := strings.IndexByte(resource, '@'); idx > 0 { + resource = resource[:idx] + } + + cmd := ParsedCommand{ + Resource: resource, + } + if len(tokens) > 1 { + cmd.Action = strings.ToLower(tokens[1]) + } + if len(tokens) > 2 { + cmd.Args = tokens[2:] + } + return cmd, nil +} + +// ExtractCommandText finds and extracts a slash command from text that may +// contain a leading @mention (e.g. "@BotName /help arg1" -> "/help arg1"). +// Returns the command text starting with "/", or empty string if none found. +func ExtractCommandText(text string) string { + trimmed := strings.TrimSpace(text) + if strings.HasPrefix(trimmed, "/") { + return trimmed + } + // Look for " /" pattern — a slash preceded by whitespace. + idx := strings.Index(trimmed, " /") + if idx >= 0 { + return strings.TrimSpace(trimmed[idx+1:]) + } + return "" +} + +// tokenize splits a command string respecting quoted segments. +func tokenize(input string) []string { + var tokens []string + var current strings.Builder + inQuote := false + quoteChar := byte(0) + + for i := 0; i < len(input); i++ { + ch := input[i] + if inQuote { + if ch == quoteChar { + inQuote = false + continue + } + current.WriteByte(ch) + continue + } + if ch == '"' || ch == '\'' { + inQuote = true + quoteChar = ch + continue + } + if ch == ' ' || ch == '\t' { + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + continue + } + current.WriteByte(ch) + } + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + return tokens +} diff --git a/internal/command/parser_test.go b/internal/command/parser_test.go new file mode 100644 index 00000000..3956c1b3 --- /dev/null +++ b/internal/command/parser_test.go @@ -0,0 +1,107 @@ +package command + +import ( + "testing" +) + +func TestParse_Basic(t *testing.T) { + t.Parallel() + tests := []struct { + input string + resource string + action string + args []string + }{ + {"/help", "help", "", nil}, + {"/subagent list", "subagent", "list", nil}, + {"/subagent get mybot", "subagent", "get", []string{"mybot"}}, + {"/schedule create daily \"0 9 * * *\" Send report", "schedule", "create", []string{"daily", "0 9 * * *", "Send", "report"}}, + {" /settings ", "settings", "", nil}, + {"/HELP", "help", "", nil}, + {"/Schedule List", "schedule", "list", nil}, + {"/help@MemohBot", "help", "", nil}, + {"/schedule@BotName list", "schedule", "list", nil}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + parsed, err := Parse(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.Resource != tt.resource { + t.Errorf("resource: got %q, want %q", parsed.Resource, tt.resource) + } + if parsed.Action != tt.action { + t.Errorf("action: got %q, want %q", parsed.Action, tt.action) + } + if len(parsed.Args) != len(tt.args) { + t.Fatalf("args length: got %d, want %d", len(parsed.Args), len(tt.args)) + } + for i, arg := range tt.args { + if parsed.Args[i] != arg { + t.Errorf("arg[%d]: got %q, want %q", i, parsed.Args[i], arg) + } + } + }) + } +} + +func TestExtractCommandText(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want string + }{ + {"/help", "/help"}, + {" /subagent list", "/subagent list"}, + {"@BotName /help", "/help"}, + {"@_user_1 /schedule list arg1", "/schedule list arg1"}, + {"<@123456> /mcp list", "/mcp list"}, + {"@bot hello", ""}, + {"hello world", ""}, + {"", ""}, + {"some text with no slash", ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + got := ExtractCommandText(tt.input) + if got != tt.want { + t.Errorf("ExtractCommandText(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParse_Errors(t *testing.T) { + t.Parallel() + tests := []string{ + "", + "hello", + "no slash", + } + for _, input := range tests { + t.Run(input, func(t *testing.T) { + t.Parallel() + _, err := Parse(input) + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} + +func TestTokenize_Quotes(t *testing.T) { + t.Parallel() + tokens := tokenize(`create daily "0 9 * * *" 'Send report now'`) + expected := []string{"create", "daily", "0 9 * * *", "Send report now"} + if len(tokens) != len(expected) { + t.Fatalf("tokens length: got %d, want %d (%v)", len(tokens), len(expected), tokens) + } + for i, tok := range expected { + if tokens[i] != tok { + t.Errorf("token[%d]: got %q, want %q", i, tokens[i], tok) + } + } +} diff --git a/internal/command/registry.go b/internal/command/registry.go new file mode 100644 index 00000000..ae879ff0 --- /dev/null +++ b/internal/command/registry.go @@ -0,0 +1,92 @@ +package command + +import ( + "context" + "fmt" + "strings" +) + +// CommandContext carries execution context for a sub-command. +type CommandContext struct { + Ctx context.Context + BotID string + Role string // "owner", "admin", "member", or "" (guest) + Args []string +} + +// SubCommand describes a single sub-command within a resource group. +type SubCommand struct { + Name string + Usage string + IsWrite bool + Handler func(cc CommandContext) (string, error) +} + +// CommandGroup groups sub-commands under a resource name. +type CommandGroup struct { + Name string + Description string + DefaultAction string + commands map[string]SubCommand + order []string // preserves registration order for help output +} + +func newCommandGroup(name, description string) *CommandGroup { + return &CommandGroup{ + Name: name, + Description: description, + commands: make(map[string]SubCommand), + } +} + +func (g *CommandGroup) Register(sub SubCommand) { + g.commands[sub.Name] = sub + g.order = append(g.order, sub.Name) +} + +// Usage returns the usage text for this resource group. +func (g *CommandGroup) Usage() string { + var b strings.Builder + fmt.Fprintf(&b, "/%s - %s\n", g.Name, g.Description) + for _, name := range g.order { + sub := g.commands[name] + perm := "" + if sub.IsWrite { + perm = " [owner]" + } + fmt.Fprintf(&b, "- %s%s\n", sub.Usage, perm) + } + return b.String() +} + +// Registry holds all registered command groups. +type Registry struct { + groups map[string]*CommandGroup + order []string +} + +func newRegistry() *Registry { + return &Registry{ + groups: make(map[string]*CommandGroup), + } +} + +func (r *Registry) RegisterGroup(group *CommandGroup) { + r.groups[group.Name] = group + r.order = append(r.order, group.Name) +} + +// GlobalHelp returns the top-level help text listing all commands. +func (r *Registry) GlobalHelp() string { + var b strings.Builder + b.WriteString("Available commands:\n\n") + b.WriteString("/help - Show this help message\n\n") + for i, name := range r.order { + if i > 0 { + b.WriteByte('\n') + } + group := r.groups[name] + b.WriteString(group.Usage()) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/command/schedule.go b/internal/command/schedule.go new file mode 100644 index 00000000..99a0a64c --- /dev/null +++ b/internal/command/schedule.go @@ -0,0 +1,201 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/schedule" +) + +func (h *Handler) buildScheduleGroup() *CommandGroup { + g := newCommandGroup("schedule", "Manage scheduled tasks") + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all schedules", + Handler: func(cc CommandContext) (string, error) { + items, err := h.scheduleService.List(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No schedules found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + records = append(records, []kv{ + {"Name", item.Name}, + {"Pattern", item.Pattern}, + {"Enabled", boolStr(item.Enabled)}, + {"Description", truncate(item.Description, 30)}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "get", + Usage: "get - Get schedule details", + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /schedule get ", nil + } + item, err := h.findScheduleByName(cc, cc.Args[0]) + if err != nil { + return "", err + } + maxCalls := "unlimited" + if item.MaxCalls != nil { + maxCalls = fmt.Sprintf("%d", *item.MaxCalls) + } + return formatKV([]kv{ + {"Name", item.Name}, + {"Description", item.Description}, + {"Pattern", item.Pattern}, + {"Command", item.Command}, + {"Enabled", boolStr(item.Enabled)}, + {"Max Calls", maxCalls}, + {"Current Calls", fmt.Sprintf("%d", item.CurrentCalls)}, + {"Created", item.CreatedAt.Format("2006-01-02 15:04:05")}, + {"Updated", item.UpdatedAt.Format("2006-01-02 15:04:05")}, + }), nil + }, + }) + g.Register(SubCommand{ + Name: "create", + Usage: "create - Create a schedule", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 3 { + return "Usage: /schedule create \nExample: /schedule create daily-report \"0 9 * * *\" \"Send daily report\"", nil + } + name := cc.Args[0] + pattern := cc.Args[1] + command := strings.Join(cc.Args[2:], " ") + item, err := h.scheduleService.Create(cc.Ctx, cc.BotID, schedule.CreateRequest{ + Name: name, + Description: name, + Pattern: pattern, + Command: command, + }) + if err != nil { + return "", err + } + return fmt.Sprintf("Schedule %q created.", item.Name), nil + }, + }) + g.Register(SubCommand{ + Name: "update", + Usage: "update [--pattern P] [--command C] - Update a schedule", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /schedule update [--pattern P] [--command C]", nil + } + item, err := h.findScheduleByName(cc, cc.Args[0]) + if err != nil { + return "", err + } + req := schedule.UpdateRequest{} + args := cc.Args[1:] + for i := 0; i < len(args); i++ { + if i+1 >= len(args) { + break + } + switch args[i] { + case "--name": + i++ + req.Name = &args[i] + case "--pattern": + i++ + req.Pattern = &args[i] + case "--command": + i++ + val := strings.Join(args[i:], " ") + req.Command = &val + i = len(args) + case "--enabled": + i++ + v := strings.ToLower(args[i]) == "true" + req.Enabled = &v + } + } + updated, err := h.scheduleService.Update(cc.Ctx, item.ID, req) + if err != nil { + return "", err + } + return fmt.Sprintf("Schedule %q updated.", updated.Name), nil + }, + }) + g.Register(SubCommand{ + Name: "delete", + Usage: "delete - Delete a schedule", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /schedule delete ", nil + } + item, err := h.findScheduleByName(cc, cc.Args[0]) + if err != nil { + return "", err + } + if err := h.scheduleService.Delete(cc.Ctx, item.ID); err != nil { + return "", err + } + return fmt.Sprintf("Schedule %q deleted.", cc.Args[0]), nil + }, + }) + g.Register(SubCommand{ + Name: "enable", + Usage: "enable - Enable a schedule", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /schedule enable ", nil + } + item, err := h.findScheduleByName(cc, cc.Args[0]) + if err != nil { + return "", err + } + enabled := true + _, err = h.scheduleService.Update(cc.Ctx, item.ID, schedule.UpdateRequest{Enabled: &enabled}) + if err != nil { + return "", err + } + return fmt.Sprintf("Schedule %q enabled.", cc.Args[0]), nil + }, + }) + g.Register(SubCommand{ + Name: "disable", + Usage: "disable - Disable a schedule", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /schedule disable ", nil + } + item, err := h.findScheduleByName(cc, cc.Args[0]) + if err != nil { + return "", err + } + enabled := false + _, err = h.scheduleService.Update(cc.Ctx, item.ID, schedule.UpdateRequest{Enabled: &enabled}) + if err != nil { + return "", err + } + return fmt.Sprintf("Schedule %q disabled.", cc.Args[0]), nil + }, + }) + return g +} + +func (h *Handler) findScheduleByName(cc CommandContext, name string) (schedule.Schedule, error) { + items, err := h.scheduleService.List(cc.Ctx, cc.BotID) + if err != nil { + return schedule.Schedule{}, err + } + for _, item := range items { + if strings.EqualFold(item.Name, name) { + return item, nil + } + } + return schedule.Schedule{}, fmt.Errorf("schedule %q not found", name) +} diff --git a/internal/command/search.go b/internal/command/search.go new file mode 100644 index 00000000..a18432c5 --- /dev/null +++ b/internal/command/search.go @@ -0,0 +1,61 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/settings" +) + +func (h *Handler) buildSearchGroup() *CommandGroup { + g := newCommandGroup("search", "Manage search provider") + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all search providers", + Handler: func(cc CommandContext) (string, error) { + items, err := h.searchProvService.List(cc.Ctx, "") + if err != nil { + return "", err + } + if len(items) == 0 { + return "No search providers found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + records = append(records, []kv{ + {"Name", item.Name}, + {"Provider", item.Provider}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "set", + Usage: "set - Set the search provider for this bot", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /search set ", nil + } + name := cc.Args[0] + items, err := h.searchProvService.List(cc.Ctx, "") + if err != nil { + return "", err + } + for _, item := range items { + if strings.EqualFold(item.Name, name) { + _, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{ + SearchProviderID: item.ID, + }) + if err != nil { + return "", err + } + return fmt.Sprintf("Search provider set to %q.", item.Name), nil + } + } + return fmt.Sprintf("Search provider %q not found.", name), nil + }, + }) + return g +} diff --git a/internal/command/settings.go b/internal/command/settings.go new file mode 100644 index 00000000..894cb4dc --- /dev/null +++ b/internal/command/settings.go @@ -0,0 +1,204 @@ +package command + +import ( + "fmt" + "strconv" + "strings" + + "github.com/memohai/memoh/internal/settings" +) + +func (h *Handler) buildSettingsGroup() *CommandGroup { + g := newCommandGroup("settings", "View and update bot settings") + g.DefaultAction = "get" + g.Register(SubCommand{ + Name: "get", + Usage: "get - View current settings", + Handler: func(cc CommandContext) (string, error) { + s, err := h.settingsService.GetBot(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + return formatKV([]kv{ + {"Language", s.Language}, + {"Allow Guest", boolStr(s.AllowGuest)}, + {"Max Context Load Time", fmt.Sprintf("%d min", s.MaxContextLoadTime)}, + {"Max Context Tokens", fmt.Sprintf("%d", s.MaxContextTokens)}, + {"Max Inbox Items", fmt.Sprintf("%d", s.MaxInboxItems)}, + {"Reasoning Enabled", boolStr(s.ReasoningEnabled)}, + {"Reasoning Effort", s.ReasoningEffort}, + {"Heartbeat Enabled", boolStr(s.HeartbeatEnabled)}, + {"Heartbeat Interval", fmt.Sprintf("%d min", s.HeartbeatInterval)}, + {"Chat Model", h.resolveModelName(cc, s.ChatModelID)}, + {"Heartbeat Model", h.resolveModelName(cc, s.HeartbeatModelID)}, + {"Search Provider", h.resolveSearchProviderName(cc, s.SearchProviderID)}, + {"Memory Provider", h.resolveMemoryProviderName(cc, s.MemoryProviderID)}, + {"Browser Context", h.resolveBrowserContextName(cc, s.BrowserContextID)}, + }), nil + }, + }) + g.Register(SubCommand{ + Name: "update", + Usage: "update [--language L] [--allow_guest true|false] ... - Update settings", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) == 0 { + return settingsUpdateUsage(), nil + } + req := settings.UpsertRequest{} + args := cc.Args + for i := 0; i < len(args); i++ { + if i+1 >= len(args) { + return fmt.Sprintf("Missing value for %s.\n\n%s", args[i], settingsUpdateUsage()), nil + } + switch args[i] { + case "--language": + i++ + req.Language = args[i] + case "--allow_guest": + i++ + v := strings.ToLower(args[i]) == "true" + req.AllowGuest = &v + case "--reasoning_enabled": + i++ + v := strings.ToLower(args[i]) == "true" + req.ReasoningEnabled = &v + case "--reasoning_effort": + i++ + req.ReasoningEffort = &args[i] + case "--heartbeat_enabled": + i++ + v := strings.ToLower(args[i]) == "true" + req.HeartbeatEnabled = &v + case "--heartbeat_interval": + i++ + val, err := strconv.Atoi(args[i]) + if err != nil { + return fmt.Sprintf("Invalid heartbeat_interval: %s", args[i]), nil + } + req.HeartbeatInterval = &val + case "--max_context_load_time": + i++ + val, err := strconv.Atoi(args[i]) + if err != nil { + return fmt.Sprintf("Invalid max_context_load_time: %s", args[i]), nil + } + req.MaxContextLoadTime = &val + case "--max_context_tokens": + i++ + val, err := strconv.Atoi(args[i]) + if err != nil { + return fmt.Sprintf("Invalid max_context_tokens: %s", args[i]), nil + } + req.MaxContextTokens = &val + case "--chat_model_id": + i++ + req.ChatModelID = args[i] + case "--heartbeat_model_id": + i++ + req.HeartbeatModelID = args[i] + default: + return fmt.Sprintf("Unknown option: %s\n\n%s", args[i], settingsUpdateUsage()), nil + } + } + _, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, req) + if err != nil { + return "", err + } + return "Settings updated.", nil + }, + }) + return g +} + +func settingsUpdateUsage() string { + return "Usage: /settings update [options]\n\n" + + "Options:\n" + + "- --language \n" + + "- --allow_guest \n" + + "- --reasoning_enabled \n" + + "- --reasoning_effort \n" + + "- --heartbeat_enabled \n" + + "- --heartbeat_interval \n" + + "- --max_context_load_time \n" + + "- --max_context_tokens \n" + + "- --chat_model_id \n" + + "- --heartbeat_model_id " +} + +func valueOrNone(s string) string { + if s == "" { + return "(none)" + } + return s +} + +// resolveModelName resolves a model UUID to "model_name (provider_name)". +func (h *Handler) resolveModelName(cc CommandContext, modelID string) string { + if modelID == "" { + return "(none)" + } + if h.modelsService == nil { + return modelID + } + m, err := h.modelsService.GetByID(cc.Ctx, modelID) + if err != nil { + return modelID + } + provName := "" + if h.providersService != nil { + p, err := h.providersService.Get(cc.Ctx, m.LlmProviderID) + if err == nil { + provName = p.Name + } + } + if provName != "" { + return fmt.Sprintf("%s (%s)", m.Name, provName) + } + return m.Name +} + +// resolveSearchProviderName resolves a search provider UUID to its name. +func (h *Handler) resolveSearchProviderName(cc CommandContext, id string) string { + if id == "" { + return "(none)" + } + if h.searchProvService == nil { + return id + } + p, err := h.searchProvService.Get(cc.Ctx, id) + if err != nil { + return id + } + return p.Name +} + +// resolveMemoryProviderName resolves a memory provider UUID to its name. +func (h *Handler) resolveMemoryProviderName(cc CommandContext, id string) string { + if id == "" { + return "(none)" + } + if h.memProvService == nil { + return id + } + p, err := h.memProvService.Get(cc.Ctx, id) + if err != nil { + return id + } + return p.Name +} + +// resolveBrowserContextName resolves a browser context UUID to its name. +func (h *Handler) resolveBrowserContextName(cc CommandContext, id string) string { + if id == "" { + return "(none)" + } + if h.browserCtxService == nil { + return id + } + p, err := h.browserCtxService.GetByID(cc.Ctx, id) + if err != nil { + return id + } + return p.Name +} diff --git a/internal/command/skill.go b/internal/command/skill.go new file mode 100644 index 00000000..42f803b1 --- /dev/null +++ b/internal/command/skill.go @@ -0,0 +1,31 @@ +package command + +func (h *Handler) buildSkillGroup() *CommandGroup { + g := newCommandGroup("skill", "View bot skills") + g.DefaultAction = "list" + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all skills", + Handler: func(cc CommandContext) (string, error) { + if h.skillLoader == nil { + return "Skill loading is not available.", nil + } + items, err := h.skillLoader.LoadSkills(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No skills found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + records = append(records, []kv{ + {"Name", item.Name}, + {"Description", truncate(item.Description, 60)}, + }) + } + return formatItems(records), nil + }, + }) + return g +} diff --git a/internal/command/subagent.go b/internal/command/subagent.go new file mode 100644 index 00000000..670b7e09 --- /dev/null +++ b/internal/command/subagent.go @@ -0,0 +1,94 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/subagent" +) + +func (h *Handler) buildSubagentGroup() *CommandGroup { + g := newCommandGroup("subagent", "Manage subagents") + g.Register(SubCommand{ + Name: "list", + Usage: "list - List all subagents", + Handler: func(cc CommandContext) (string, error) { + items, err := h.subagentService.List(cc.Ctx, cc.BotID) + if err != nil { + return "", err + } + if len(items) == 0 { + return "No subagents found.", nil + } + records := make([][]kv, 0, len(items)) + for _, item := range items { + records = append(records, []kv{ + {"Name", item.Name}, + {"Description", truncate(item.Description, 40)}, + }) + } + return formatItems(records), nil + }, + }) + g.Register(SubCommand{ + Name: "get", + Usage: "get - Get subagent details", + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /subagent get ", nil + } + name := cc.Args[0] + item, err := h.subagentService.GetByBotAndName(cc.Ctx, cc.BotID, name) + if err != nil { + return "", fmt.Errorf("subagent %q not found", name) + } + return formatKV([]kv{ + {"Name", item.Name}, + {"Description", item.Description}, + {"Skills", fmt.Sprintf("%v", item.Skills)}, + {"Created", item.CreatedAt.Format("2006-01-02 15:04:05")}, + {"Updated", item.UpdatedAt.Format("2006-01-02 15:04:05")}, + }), nil + }, + }) + g.Register(SubCommand{ + Name: "create", + Usage: "create - Create a subagent", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 2 { + return "Usage: /subagent create ", nil + } + name := cc.Args[0] + description := strings.Join(cc.Args[1:], " ") + item, err := h.subagentService.Create(cc.Ctx, cc.BotID, subagent.CreateRequest{ + Name: name, + Description: description, + }) + if err != nil { + return "", err + } + return fmt.Sprintf("Subagent %q created (ID: %s).", item.Name, item.ID), nil + }, + }) + g.Register(SubCommand{ + Name: "delete", + Usage: "delete - Delete a subagent", + IsWrite: true, + Handler: func(cc CommandContext) (string, error) { + if len(cc.Args) < 1 { + return "Usage: /subagent delete ", nil + } + name := cc.Args[0] + item, err := h.subagentService.GetByBotAndName(cc.Ctx, cc.BotID, name) + if err != nil { + return "", fmt.Errorf("subagent %q not found", name) + } + if err := h.subagentService.Delete(cc.Ctx, item.ID); err != nil { + return "", err + } + return fmt.Sprintf("Subagent %q deleted.", name), nil + }, + }) + return g +} diff --git a/internal/command/usage.go b/internal/command/usage.go new file mode 100644 index 00000000..a89f3f03 --- /dev/null +++ b/internal/command/usage.go @@ -0,0 +1,144 @@ +package command + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + dbsqlc "github.com/memohai/memoh/internal/db/sqlc" +) + +func (h *Handler) buildUsageGroup() *CommandGroup { + g := newCommandGroup("usage", "View token usage") + g.DefaultAction = "summary" + g.Register(SubCommand{ + Name: "summary", + Usage: "summary - Token usage summary (last 7 days)", + Handler: func(cc CommandContext) (string, error) { + botUUID, err := parseBotUUID(cc.BotID) + if err != nil { + return "", err + } + now := time.Now().UTC() + from := now.AddDate(0, 0, -7) + fromTS := pgtype.Timestamptz{Time: from, Valid: true} + toTS := pgtype.Timestamptz{Time: now, Valid: true} + nullModel := pgtype.UUID{Valid: false} + + chatRows, err := h.queries.GetMessageTokenUsageByDay(cc.Ctx, dbsqlc.GetMessageTokenUsageByDayParams{ + BotID: botUUID, FromTime: fromTS, ToTime: toTS, ModelID: nullModel, + }) + if err != nil { + return "", err + } + + hbRows, err := h.queries.GetHeartbeatTokenUsageByDay(cc.Ctx, dbsqlc.GetHeartbeatTokenUsageByDayParams{ + BotID: botUUID, FromTime: fromTS, ToTime: toTS, ModelID: nullModel, + }) + if err != nil { + return "", err + } + + if len(chatRows) == 0 && len(hbRows) == 0 { + return "No token usage in the last 7 days.", nil + } + + var b strings.Builder + b.WriteString("Token usage (last 7 days):\n\n") + + if len(chatRows) > 0 { + b.WriteString("Chat:\n") + var totalIn, totalOut int64 + for _, r := range chatRows { + day := r.Day.Time.Format("01-02") + fmt.Fprintf(&b, " %s: in=%d out=%d\n", day, r.InputTokens, r.OutputTokens) + totalIn += r.InputTokens + totalOut += r.OutputTokens + } + fmt.Fprintf(&b, " Total: in=%d out=%d\n", totalIn, totalOut) + } + + if len(hbRows) > 0 { + if len(chatRows) > 0 { + b.WriteByte('\n') + } + b.WriteString("Heartbeat:\n") + var totalIn, totalOut int64 + for _, r := range hbRows { + day := r.Day.Time.Format("01-02") + fmt.Fprintf(&b, " %s: in=%d out=%d\n", day, r.InputTokens, r.OutputTokens) + totalIn += r.InputTokens + totalOut += r.OutputTokens + } + fmt.Fprintf(&b, " Total: in=%d out=%d\n", totalIn, totalOut) + } + + return strings.TrimRight(b.String(), "\n"), nil + }, + }) + g.Register(SubCommand{ + Name: "by-model", + Usage: "by-model - Token usage grouped by model", + Handler: func(cc CommandContext) (string, error) { + botUUID, err := parseBotUUID(cc.BotID) + if err != nil { + return "", err + } + now := time.Now().UTC() + from := now.AddDate(0, 0, -7) + fromTS := pgtype.Timestamptz{Time: from, Valid: true} + toTS := pgtype.Timestamptz{Time: now, Valid: true} + + chatRows, err := h.queries.GetMessageTokenUsageByModel(cc.Ctx, dbsqlc.GetMessageTokenUsageByModelParams{ + BotID: botUUID, FromTime: fromTS, ToTime: toTS, + }) + if err != nil { + return "", err + } + hbRows, err := h.queries.GetHeartbeatTokenUsageByModel(cc.Ctx, dbsqlc.GetHeartbeatTokenUsageByModelParams{ + BotID: botUUID, FromTime: fromTS, ToTime: toTS, + }) + if err != nil { + return "", err + } + + if len(chatRows) == 0 && len(hbRows) == 0 { + return "No token usage in the last 7 days.", nil + } + + var b strings.Builder + b.WriteString("Token usage by model (last 7 days):\n\n") + + if len(chatRows) > 0 { + b.WriteString("Chat:\n") + for _, r := range chatRows { + fmt.Fprintf(&b, " %s (%s): in=%d out=%d\n", r.ModelName, r.ProviderName, r.InputTokens, r.OutputTokens) + } + } + + if len(hbRows) > 0 { + if len(chatRows) > 0 { + b.WriteByte('\n') + } + b.WriteString("Heartbeat:\n") + for _, r := range hbRows { + fmt.Fprintf(&b, " %s (%s): in=%d out=%d\n", r.ModelName, r.ProviderName, r.InputTokens, r.OutputTokens) + } + } + + return strings.TrimRight(b.String(), "\n"), nil + }, + }) + return g +} + +func parseBotUUID(botID string) (pgtype.UUID, error) { + parsed, err := uuid.Parse(botID) + if err != nil { + return pgtype.UUID{}, fmt.Errorf("invalid bot ID: %w", err) + } + return pgtype.UUID{Bytes: parsed, Valid: true}, nil +}