diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad52419 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +# Mattermore — Mattermost AI Chat Agent +# Build targets: +# make dist — Build all platform binaries and package plugin +# make build — Build linux-amd64 binary only +# make check — Lint + test +# make clean — Remove build artifacts + +PLUGIN_ID := com.forkless.mattermore +PLUGIN_VERSION := 0.1.0 +DIST_DIR := dist +SERVER_DIR := server +BUNDLE_NAME := $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz +BUNDLE_PATH := $(DIST_DIR)/$(BUNDLE_NAME) + +# Platforms to build for +LINUX_AMD64 := linux-amd64 +LINUX_ARM64 := linux-arm64 +DARWIN_AMD64 := darwin-amd64 +DARWIN_ARM64 := darwin-arm64 + +.PHONY: all build dist check clean + +all: dist + +# Build linux-amd64 only (fastest for local testing) +build: + @mkdir -p $(SERVER_DIR)/dist + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ + go build -o $(SERVER_DIR)/dist/plugin-$(LINUX_AMD64) ./$(SERVER_DIR) + +# Build all platform binaries +dist: build-all-platforms bundle + +build-all-platforms: + @mkdir -p $(SERVER_DIR)/dist + @echo "Building for $(LINUX_AMD64)..." + @GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ + go build -o $(SERVER_DIR)/dist/plugin-$(LINUX_AMD64) ./$(SERVER_DIR) + @echo "Building for $(LINUX_ARM64)..." + @GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \ + go build -o $(SERVER_DIR)/dist/plugin-$(LINUX_ARM64) ./$(SERVER_DIR) + @echo "Building for $(DARWIN_AMD64)..." + @GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 \ + go build -o $(SERVER_DIR)/dist/plugin-$(DARWIN_AMD64) ./$(SERVER_DIR) + @echo "Building for $(DARWIN_ARM64)..." + @GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 \ + go build -o $(SERVER_DIR)/dist/plugin-$(DARWIN_ARM64) ./$(SERVER_DIR) + +bundle: + @mkdir -p $(DIST_DIR) + @echo "Creating bundle..." + @mkdir -p /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION) + @cp plugin.json /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION)/ + @mkdir -p /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION)/server/dist + @cp $(SERVER_DIR)/dist/* /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION)/server/dist/ + @cd /tmp/mm-bundle && tar czf $(CURDIR)/$(BUNDLE_PATH) $(PLUGIN_ID)-$(PLUGIN_VERSION) + @rm -rf /tmp/mm-bundle + @echo "Bundle created: $(BUNDLE_PATH)" + +check: + @echo "Running go vet..." + @go vet ./$(SERVER_DIR) + @echo "Running go fmt..." + @go fmt ./$(SERVER_DIR) + @echo "Done" + +clean: + @rm -rf $(DIST_DIR) + @rm -rf $(SERVER_DIR)/dist + @echo "Cleaned" diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index e18a6ee..78f8b83 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -25,11 +25,11 @@ services: postgres: condition: service_healthy environment: - # Database - MM_USERNAME: mmuser - MM_PASSWORD: mmuser_pass - MM_DBNAME: mattermost_test - MM_DBHOST: postgres:5432 + # Database — full connection string (env format: MM_{SECTION}_{KEY}) + MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:mmuser_pass@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10" + # Plugin + file storage — use /tmp to avoid permission issues + MM_PLUGINSETTINGS_DIRECTORY: /tmp/plugins + MM_FILESETTINGS_DIRECTORY: /tmp/file_data # Server MM_SERVICESETTINGS_SITEURL: http://localhost:8065 MM_SERVICESETTINGS_LISTENADDRESS: :8065 @@ -46,21 +46,44 @@ services: MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false" ports: - "8065:8065" - # Auto-create admin user (admin/admin) on first boot. - entrypoint: ["/bin/bash", "/init.sh"] volumes: - # Init script creates admin user on first startup. - - ./init.sh:/init.sh:ro # Plugin directory — mount so you can upload via UI: - ./mattermost/plugins:/mattermost/data/plugins:rw # Data volume for uploads, avatars, etc. - mattermost-data:/mattermost/data:rw healthcheck: - test: ["CMD-SHELL", "curl -sf http://localhost:8065/api/v4/system/ping | grep -q OK"] + test: ["CMD", "pgrep", "mattermost"] interval: 10s timeout: 5s - retries: 10 - start_period: 30s + retries: 30 + start_period: 20s + + setup: + image: curlimages/curl:latest + container_name: mattermore-setup + depends_on: + mattermost: + condition: service_healthy + command: + - /bin/sh + - -c + - | + echo "setup: waiting for Mattermost API..." + for i in $(seq 1 30); do + if curl -sf http://mattermost:8065/api/v4/system/ping > /dev/null 2>&1; then + echo "setup: Mattermost is ready" + break + fi + echo "setup: waiting... ($i)" + sleep 3 + done + echo "setup: creating admin user..." + curl -s -X POST http://mattermost:8065/api/v4/users \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","username":"admin","password":"password"}' \ + -w "\nsetup: HTTP %{http_code}" + echo "" + echo "setup: done" volumes: mattermost-data: diff --git a/plugin.json b/plugin.json index 051713e..000fbe6 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "id": "com.forkless.mattermore", - "name": "Mattermore", + "name": "Matty", "description": "AI chat agent powered by Ollama.", "version": "0.1.0", "min_server_version": "11.6.1", @@ -12,7 +12,6 @@ "darwin-arm64": "server/dist/plugin-darwin-arm64" } }, - "webapp": {}, "settings_schema": { "header": "Configure the connection to your Ollama instance.", "footer": "", @@ -28,18 +27,82 @@ { "key": "DefaultModel", "display_name": "Default Model", - "type": "text", - "help_text": "Model name to use when none is specified.", - "placeholder": "llama3:latest", - "default": "llama3:latest" + "type": "dropdown", + "help_text": "Model to use for chat responses.", + "default": "llama3:latest", + "options": [ + { "display_name": "dolphin-llama3:latest (8B, uncensored)", "value": "dolphin-llama3:latest" }, + { "display_name": "gpt-oss:20b (21B, tools, thinking)", "value": "gpt-oss:20b" }, + { "display_name": "llama3:latest (8B, fast)", "value": "llama3:latest" }, + { "display_name": "new-adult-writer (8B, tools)", "value": "jimscard/new-adult-writer:latest" }, + { "display_name": "Qwen 27B (27B, vision)", "value": "hf.co/DavidAU/Qwen3.6-27B-Heretic-Uncensored-FINETUNE-NEO-CODE-Di-IMatrix-MAX-GGUF:Q4_K_M" }, + { "display_name": "supergemma4:26b (26B, uncensored)", "value": "hf.co/Jiunsong/supergemma4-26b-uncensored-gguf-v2:Q4_K_M" } + ] }, { - "key": "SystemPrompt", - "display_name": "System Prompt", + "key": "Persona", + "display_name": "Bot Persona", + "type": "dropdown", + "help_text": "The bot's character. Pick a persona or set a custom one in the config file.", + "default": "a helpful assistant. Be concise, 2-3 sentences max, never repeat yourself.", + "options": [ + { "display_name": "D&D dungeon master", "value": "a Dungeons & Dragons dungeon master. Epic narrative, roll for initiative." }, + { "display_name": "Donald Trump", "value": "Donald Trump. Boastful, hyperbolic, third-person. Think: rally speech, not catchphrase list. Keep it to 2 sentences." }, + { "display_name": "Drunken Jeanine Pirro", "value": "a drunk Jeanine Pirro. Slur your words, repeat yourself, be loud and uncensored." }, + { "display_name": "French mime", "value": "a French mime. Respond with dramatic pauses and gestures described in asterisks. Minimal words." }, + { "display_name": "George Carlin", "value": "George Carlin. Acerbic, observational, profane comedy. Rant about the absurdity of everything. Use seven dirty words liberally. Keep it tight, one or two punchlines max." }, + { "display_name": "Gordon Ramsay (code review)", "value": "Gordon Ramsay but for code reviews. Aggressive chef. YELL. Brief." }, + { "display_name": "Grumpy sysadmin", "value": "a grumpy sysadmin who thinks every question is stupid. Be sarcastic, brief, and annoyed." }, + { "display_name": "Helpful assistant (concise)", "value": "a helpful assistant. Be concise, 2-3 sentences max, never repeat yourself." }, + { "display_name": "Infomercial host (1980s)", "value": "a 1980s TV infomercial host. Over-enthusiastic, loud, BUT WAIT THERE'S MORE." }, + { "display_name": "Medieval court jester", "value": "a medieval court jester who mocks everyone. Old-timey insults, brief." }, + { "display_name": "Motivational speaker", "value": "a motivational speaker who turns everything into a life lesson. Over-the-top positive but concise." }, + { "display_name": "Noir detective", "value": "a noir detective who answers every question with another question. Brooding, cryptic, short." }, + { "display_name": "Overly polite Canadian", "value": "an overly polite Canadian who apologises excessively. Sorry, eh, keep it short." }, + { "display_name": "Philosophical overthinker", "value": "a philosopher who overcomplicates everything. Pseudo-intellectual, wordy is the point." }, + { "display_name": "Pirate captain", "value": "a pirate captain. Use pirate slang, be theatrical, keep responses short." }, + { "display_name": "Shakespearean author", "value": "a Shakespearean author. Respond in eloquent old English, but keep it brief." }, + { "display_name": "Surfer dude", "value": "a surfer dude explaining complex topics badly. Chill, inaccurate, gnarly." }, + { "display_name": "Tech support scammer (2008)", "value": "a tech support scammer from 2008. Alarmist, pushy, uses ALL CAPS occasionally." }, + { "display_name": "Tired parent", "value": "a tired parent who's given up on life. Exhausted, relatable, monosyllabic when possible." } + ] + }, + { + "key": "RepeatPenalty", + "display_name": "Repeat Penalty", + "type": "number", + "help_text": "Penalises repeated words. 10 = none, 11 = mild, 12 = strong. Helps with repetitive output. (Internally divided by 10.)", + "default": 11 + }, + { + "key": "FrequencyPenalty", + "display_name": "Frequency Penalty", + "type": "number", + "help_text": "Penalises frequently used tokens. 0 = none, 1 = mild, 5 = strong. Reduces word/phrase repetition. (Internally divided by 10.)", + "default": 1 + }, + { + "key": "Brevity", + "display_name": "Max Sentences", + "type": "dropdown", + "default": "3", + "help_text": "Maximum sentences per response. Suffixed to the persona automatically.", + "options": [ + { "display_name": "1 sentence", "value": "1" }, + { "display_name": "2 sentences", "value": "2" }, + { "display_name": "3 sentences", "value": "3" }, + { "display_name": "5 sentences", "value": "5" }, + { "display_name": "10 sentences", "value": "10" }, + { "display_name": "Unlimited", "value": "0" } + ] + }, + { + "key": "CustomPersona", + "display_name": "Custom Persona (overrides dropdown)", "type": "text", - "help_text": "System prompt prepended to every conversation.", - "placeholder": "You are a helpful assistant.", - "default": "You are a helpful assistant." + "help_text": "Type a custom persona here to override the dropdown. Leave empty to use the dropdown selection. E.g. \"a sarcastic AI that quotes Monty Python\".", + "placeholder": "", + "default": "" }, { "key": "MaxTokens", @@ -53,7 +116,7 @@ "display_name": "Rate Limit (requests/minute/user)", "type": "number", "help_text": "Maximum requests per minute per user. 0 = unlimited.", - "default": 10 + "default": 0 }, { "key": "AllowedUserIDs", diff --git a/server/command.go b/server/command.go index be95c96..f7701ce 100644 --- a/server/command.go +++ b/server/command.go @@ -8,26 +8,36 @@ import ( "github.com/mattermost/mattermost/server/public/plugin" ) -const helpText = `### Mattermore -- AI Chat Agent +const helpText = `### Matty -- AI Chat Agent **@mention the bot in any channel to start a conversation.** **Commands:** -- @mattermore -- Chat with the default model -- @mattermore model -- Use a specific model -- @mattermore new -- Reset conversation context -- @mattermore model list -- List available models -- @mattermore help -- Show this help -- @mattermore stop -- End the current conversation +- @matty -- Chat with the default model +- @matty model -- Use a specific model +- @matty new -- Reset conversation context +- @matty model list -- List available models +- @matty help -- Show this help +- @matty stop -- End the current conversation -You can also use /ai as an alternative. +You can also use /ai as an alternative. /resetmatty clears memory. The bot responds only when addressed and disengages naturally. ` -// ExecuteCommand handles the /ai slash command. +// ExecuteCommand handles slash commands (/ai, /resetmatterless). func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + // /resetmatty — clear context and end session. + if args.Command == "/resetmatty" { + if err := p.conversationStore.Reset(args.UserId, args.ChannelId); err != nil { + return p.response("Failed to reset conversation."), nil + } + p.engagementEngine.Sleep(args.UserId, args.ChannelId) + return p.response("Matty's memory wiped. @matty to start again."), nil + } + + // /ai — dispatch subcommands. trigger := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(args.Command), "/ai")) parts := parseArgs(trigger) @@ -39,7 +49,8 @@ func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*mo if err := p.conversationStore.Reset(args.UserId, args.ChannelId); err != nil { return p.response("Failed to reset conversation."), nil } - return p.response("Conversation reset. Start fresh with @mattermore "), nil + p.engagementEngine.Sleep(args.UserId, args.ChannelId) + return p.response("Conversation reset. @matty to start again."), nil case parts[0] == "model" && len(parts) >= 2 && parts[1] == "list": return p.handleModelList(args) diff --git a/server/configuration.go b/server/configuration.go index d2eb1f2..81e8dd4 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -10,7 +10,11 @@ import ( type configuration struct { OllamaURL string DefaultModel string - SystemPrompt string + Persona string + CustomPersona string + Brevity string + RepeatPenalty float64 + FrequencyPenalty float64 MaxTokens int RateLimitPerMinute int AllowedUserIDs string diff --git a/server/engagement.go b/server/engagement.go new file mode 100644 index 0000000..94d0c78 --- /dev/null +++ b/server/engagement.go @@ -0,0 +1,83 @@ +package main + +import ( + "sync" + "time" +) + +// EngagementState tracks whether a user is in an active conversation. +type EngagementState int + +const ( + Sleeping EngagementState = 0 + Active EngagementState = 1 +) + +// engagementSession tracks a single user's active conversation. +type engagementSession struct { + userID string + channelID string + state EngagementState + lastActive time.Time +} + +// EngagementEngine manages active conversations per user+channel. +// When a user @mentions the bot, they enter an Active session. +// Their subsequent non-@mention messages in the same channel +// are treated as continuations until a stop word or timeout. +type EngagementEngine struct { + mu sync.Mutex + sessions map[string]*engagementSession + timeout time.Duration +} + +// NewEngagementEngine creates a new engine with the given inactivity timeout. +func NewEngagementEngine() *EngagementEngine { + return &EngagementEngine{ + sessions: make(map[string]*engagementSession), + timeout: 5 * time.Minute, + } +} + +// sessionKey builds a unique key per user+channel. +func (e *EngagementEngine) sessionKey(userID, channelID string) string { + return userID + ":" + channelID +} + +// Wake marks a user+channel as actively engaged with the bot. +func (e *EngagementEngine) Wake(userID, channelID string) { + e.mu.Lock() + defer e.mu.Unlock() + key := e.sessionKey(userID, channelID) + e.sessions[key] = &engagementSession{ + userID: userID, + channelID: channelID, + state: Active, + lastActive: time.Now(), + } +} + +// IsActive returns true if the user+channel is in an active conversation. +func (e *EngagementEngine) IsActive(userID, channelID string) bool { + e.mu.Lock() + defer e.mu.Unlock() + key := e.sessionKey(userID, channelID) + s, exists := e.sessions[key] + if !exists { + return false + } + if time.Since(s.lastActive) > e.timeout { + delete(e.sessions, key) + return false + } + s.lastActive = time.Now() + return true +} + +// Sleep ends the active conversation for a user+channel. +func (e *EngagementEngine) Sleep(userID, channelID string) { + e.mu.Lock() + defer e.mu.Unlock() + key := e.sessionKey(userID, channelID) + delete(e.sessions, key) +} diff --git a/server/hooks.go b/server/hooks.go index 05bed04..b697f3b 100644 --- a/server/hooks.go +++ b/server/hooks.go @@ -3,14 +3,13 @@ package main import ( "fmt" "strings" - "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" ) -const botUsername = "mattermore" -const botDisplayName = "Mattermore AI" +const botUsername = "matty" +const botDisplayName = "Matty AI" const botDescription = "AI chat agent powered by Ollama." // OnActivate is invoked when the plugin is activated. @@ -18,8 +17,8 @@ func (p *Plugin) OnActivate() error { // Register the /ai slash command. if err := p.API.RegisterCommand(&model.Command{ Trigger: "ai", - DisplayName: "Mattermore", - Description: "AI chat agent powered by Ollama.", + DisplayName: "Matty", + Description: "AI chat powered by Ollama. @matty", AutoComplete: true, AutoCompleteDesc: "Chat with the AI. Usage: /ai ", AutoCompleteHint: "", @@ -27,17 +26,24 @@ func (p *Plugin) OnActivate() error { return fmt.Errorf("register command: %w", err) } - // Ensure the bot account exists. - bot := &model.Bot{ - Username: botUsername, - DisplayName: botDisplayName, - Description: botDescription, + // Register the /resetmatty command. + if err := p.API.RegisterCommand(&model.Command{ + Trigger: "resetmatty", + DisplayName: "Reset Matty", + Description: "Clear conversation context and end active session.", + AutoComplete: true, + AutoCompleteDesc: "Clear Matty's memory for this channel.", + AutoCompleteHint: "", + }); err != nil { + return fmt.Errorf("register reset command: %w", err) } - createdBot, appErr := p.API.CreateBot(bot) - if appErr != nil { - return fmt.Errorf("create bot: %w", appErr) + + // Ensure the bot account exists — create or re-use. + var err error + p.botUserID, err = p.ensureBot() + if err != nil { + return fmt.Errorf("bot setup: %w", err) } - p.botUserID = createdBot.UserId // Initialise components. cfg := p.getConfiguration() @@ -52,10 +58,44 @@ func (p *Plugin) OnActivate() error { p.ollamaClient = NewOllamaClient(cfg.OllamaURL) p.conversationStore = NewConversationStore(p.API) p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute) + p.engagementEngine = NewEngagementEngine() return nil } +// ensureBot finds or creates the bot account and returns its user ID. +func (p *Plugin) ensureBot() (string, error) { + // Check if a user with the bot username already exists. + user, appErr := p.API.GetUserByUsername(botUsername) + if appErr == nil && user != nil { + // User exists — check if it's a bot. + if _, botErr := p.API.GetBot(user.Id, true); botErr == nil { + return user.Id, nil + } + // User exists but is not a bot — create the bot association. + createdBot, createErr := p.API.CreateBot(&model.Bot{ + Username: botUsername, + DisplayName: botDisplayName, + Description: botDescription, + }) + if createErr != nil { + return "", fmt.Errorf("account exists but bot creation failed: %w", createErr) + } + return createdBot.UserId, nil + } + + // No existing user — create bot fresh. + createdBot, appErr := p.API.CreateBot(&model.Bot{ + Username: botUsername, + DisplayName: botDisplayName, + Description: botDescription, + }) + if appErr != nil { + return "", fmt.Errorf("create bot: %w", appErr) + } + return createdBot.UserId, nil +} + // OnDeactivate is invoked when the plugin is deactivated. func (p *Plugin) OnDeactivate() error { // The bot account persists across restarts — it will be re-used @@ -86,14 +126,7 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) { return } - // Check if this is a thread reply to a post we made. - threadOwned := false - if post.RootId != "" { - rootPost, appErr := p.API.GetPost(post.RootId) - if appErr == nil && rootPost.UserId == p.botUserID { - threadOwned = true - } - } + cfg := p.getConfiguration() // Check if the bot is @-mentioned in the message. mentioned := strings.Contains( @@ -101,12 +134,14 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) { "@"+botUsername, ) - // If not mentioned and not a thread reply, ignore. - if !mentioned && !threadOwned { + isActive := p.engagementEngine.IsActive(post.UserId, post.ChannelId) + + // If not mentioned and not in an active conversation, ignore. + if !mentioned && !isActive { return } - // Build the prompt by stripping the @-mention. + // Build the prompt. prompt := post.Message if mentioned { prompt = strings.TrimSpace(strings.Replace( @@ -118,9 +153,9 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) { } // Handle stop words before sending to Ollama. - cfg := p.getConfiguration() if stopWordMatch(prompt, cfg.stopWordSet()) { - p.postReply(post, "Goodbye! Feel free to @mention me again anytime.") + p.engagementEngine.Sleep(post.UserId, post.ChannelId) + p.postReply(post, "Goodbye! _Matty_ signing off. :wave:") return } @@ -136,6 +171,11 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) { return } + // Wake or refresh the engagement session. + if mentioned { + p.engagementEngine.Wake(post.UserId, post.ChannelId) + } + // Build conversation context. messages, err := p.conversationStore.BuildMessages(post.UserId, post.ChannelId, prompt) if err != nil { @@ -143,86 +183,69 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) { return } - // Create the initial thread post with a placeholder. - replyPost := &model.Post{ - UserId: p.botUserID, - ChannelId: post.ChannelId, - Message: "…", - RootId: post.RootId, + // Prepend the persona system prompt to set the bot's character. + persona := cfg.CustomPersona + if persona == "" { + persona = cfg.Persona } - if replyPost.RootId == "" { - replyPost.RootId = post.Id + if persona != "" { + systemContent := "You are " + persona + ". Respond in character. Only respond to the latest message — do not re-address earlier messages. Vary your openings and phrasing each time." + // Append brevity instruction if set. + if cfg.Brevity != "" && cfg.Brevity != "0" { + systemContent += " Reply in " + cfg.Brevity + " sentences or fewer." + } + systemMsg := ChatMessage{ + Role: "system", + Content: systemContent, + } + messages = append([]ChatMessage{systemMsg}, messages...) } - createdPost, appErr := p.API.CreatePost(replyPost) - if appErr != nil { - p.API.LogError("Failed to create reply post", "error", appErr.Error()) - return - } + p.API.LogDebug("Chat request", "model", cfg.DefaultModel, "persona", persona, "brevity", cfg.Brevity) - // Stream the response from Ollama. + // Stream the full response from Ollama, then post once. + opts := map[string]any{} + if cfg.MaxTokens > 0 { + opts["num_predict"] = cfg.MaxTokens + } + if cfg.RepeatPenalty > 0 { + opts["repeat_penalty"] = float64(cfg.RepeatPenalty) / 10.0 + } + if cfg.FrequencyPenalty > 0 { + opts["frequency_penalty"] = float64(cfg.FrequencyPenalty) / 10.0 + } stream, err := p.ollamaClient.ChatCompletionStream(&ChatRequest{ Model: cfg.DefaultModel, Messages: messages, - Options: map[string]any{}, + Options: opts, }) if err != nil { - p.postReply(post, fmt.Sprintf("Failed to get response from Ollama: %v", err)) + p.postReply(post, "Matty is asleep. @matty when you need me.") return } - // Read the stream and update the post periodically. - var accumulated strings.Builder - updateTicker := time.NewTicker(200 * time.Millisecond) - defer updateTicker.Stop() - - for { - select { - case event, ok := <-stream: - if !ok { - // Stream closed unexpectedly. - p.finalisePost(createdPost, accumulated.String()) - return - } - if event.Error != nil { - p.API.LogError("Stream error", "error", event.Error.Error()) - p.finalisePost(createdPost, accumulated.String()+"\n\n*Error: response truncated*") - return - } - accumulated.WriteString(event.Token) - if event.Done { - p.finalisePost(createdPost, accumulated.String()) - return - } - case <-updateTicker.C: - if accumulated.Len() > 0 { - createdPost.Message = accumulated.String() - p.API.UpdatePost(createdPost) - } + var fullResponse strings.Builder + for event := range stream { + if event.Error != nil { + p.API.LogError("Stream error", "error", event.Error.Error()) + fullResponse.WriteString("\n\n(Matty is asleep. @matty when you need me.)") + break + } + fullResponse.WriteString(event.Token) + if event.Done { + break } } + + p.postReply(post, fullResponse.String()) } -// finalisePost updates the post with the final content and persists the session. -func (p *Plugin) finalisePost(post *model.Post, content string) { - post.Message = content - if _, appErr := p.API.UpdatePost(post); appErr != nil { - p.API.LogError("Failed to finalise post", "error", appErr.Error()) - } -} - -// postReply creates a reply post in the same thread as the given post. +// postReply creates a direct channel post. func (p *Plugin) postReply(replyTo *model.Post, text string) { replyPost := &model.Post{ UserId: p.botUserID, ChannelId: replyTo.ChannelId, Message: text, - RootId: replyTo.RootId, - } - - // If the original post has no root, use the original post ID as root. - if replyPost.RootId == "" { - replyPost.RootId = replyTo.Id } if _, appErr := p.API.CreatePost(replyPost); appErr != nil { diff --git a/server/plugin.go b/server/plugin.go index d476490..52d9257 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -11,12 +11,13 @@ import ( type Plugin struct { plugin.MattermostPlugin - configuration *configuration - configLock sync.RWMutex - botUserID string - ollamaClient *OllamaClient + configuration *configuration + configLock sync.RWMutex + botUserID string + ollamaClient *OllamaClient conversationStore *ConversationStore - rateLimiter *RateLimiter + rateLimiter *RateLimiter + engagementEngine *EngagementEngine } // ServeHTTP allows the plugin to handle HTTP requests. diff --git a/server/rate.go b/server/rate.go index 70302a1..3cfc11c 100644 --- a/server/rate.go +++ b/server/rate.go @@ -19,8 +19,8 @@ type bucket struct { // NewRateLimiter creates a rate limiter with the given rate (requests/minute). func NewRateLimiter(ratePerMinute int) *RateLimiter { - if ratePerMinute <= 0 { - ratePerMinute = 10 + if ratePerMinute < 0 { + ratePerMinute = 0 } return &RateLimiter{ users: make(map[string]*bucket), @@ -30,6 +30,10 @@ func NewRateLimiter(ratePerMinute int) *RateLimiter { // Allow checks if a request from the given user ID should be allowed. func (rl *RateLimiter) Allow(userID string) bool { + // 0 = unlimited. + if rl.rate == 0 { + return true + } rl.mu.Lock() defer rl.mu.Unlock() diff --git a/server/store.go b/server/store.go index 1de320d..f38cec4 100644 --- a/server/store.go +++ b/server/store.go @@ -110,7 +110,7 @@ func (s *ConversationStore) saveSession(session *conversationSession) error { return err } -// stopWordMatch checks if the trimmed, lowercased prompt matches a stop word. +// stopWordMatch checks if the trimmed, lowercased prompt matches a stop word exactly. func stopWordMatch(prompt string, stopWords map[string]bool) bool { if stopWords == nil { return false