Add EngagementEngine, persona system, dropdowns, penalties, /resetmatty, debug logging

This commit is contained in:
2026-06-16 19:45:50 +02:00
parent 83da1698c0
commit 31a81fbfaa
10 changed files with 411 additions and 129 deletions
+70
View File
@@ -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"
+35 -12
View File
@@ -25,11 +25,11 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
# Database # Database — full connection string (env format: MM_{SECTION}_{KEY})
MM_USERNAME: mmuser MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:mmuser_pass@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10"
MM_PASSWORD: mmuser_pass # Plugin + file storage — use /tmp to avoid permission issues
MM_DBNAME: mattermost_test MM_PLUGINSETTINGS_DIRECTORY: /tmp/plugins
MM_DBHOST: postgres:5432 MM_FILESETTINGS_DIRECTORY: /tmp/file_data
# Server # Server
MM_SERVICESETTINGS_SITEURL: http://localhost:8065 MM_SERVICESETTINGS_SITEURL: http://localhost:8065
MM_SERVICESETTINGS_LISTENADDRESS: :8065 MM_SERVICESETTINGS_LISTENADDRESS: :8065
@@ -46,21 +46,44 @@ services:
MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false" MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false"
ports: ports:
- "8065:8065" - "8065:8065"
# Auto-create admin user (admin/admin) on first boot.
entrypoint: ["/bin/bash", "/init.sh"]
volumes: volumes:
# Init script creates admin user on first startup.
- ./init.sh:/init.sh:ro
# Plugin directory — mount so you can upload via UI: # Plugin directory — mount so you can upload via UI:
- ./mattermost/plugins:/mattermost/data/plugins:rw - ./mattermost/plugins:/mattermost/data/plugins:rw
# Data volume for uploads, avatars, etc. # Data volume for uploads, avatars, etc.
- mattermost-data:/mattermost/data:rw - mattermost-data:/mattermost/data:rw
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8065/api/v4/system/ping | grep -q OK"] test: ["CMD", "pgrep", "mattermost"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 30
start_period: 30s 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: volumes:
mattermost-data: mattermost-data:
+75 -12
View File
@@ -1,6 +1,6 @@
{ {
"id": "com.forkless.mattermore", "id": "com.forkless.mattermore",
"name": "Mattermore", "name": "Matty",
"description": "AI chat agent powered by Ollama.", "description": "AI chat agent powered by Ollama.",
"version": "0.1.0", "version": "0.1.0",
"min_server_version": "11.6.1", "min_server_version": "11.6.1",
@@ -12,7 +12,6 @@
"darwin-arm64": "server/dist/plugin-darwin-arm64" "darwin-arm64": "server/dist/plugin-darwin-arm64"
} }
}, },
"webapp": {},
"settings_schema": { "settings_schema": {
"header": "Configure the connection to your Ollama instance.", "header": "Configure the connection to your Ollama instance.",
"footer": "", "footer": "",
@@ -28,18 +27,82 @@
{ {
"key": "DefaultModel", "key": "DefaultModel",
"display_name": "Default Model", "display_name": "Default Model",
"type": "text", "type": "dropdown",
"help_text": "Model name to use when none is specified.", "help_text": "Model to use for chat responses.",
"placeholder": "llama3:latest", "default": "llama3:latest",
"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", "key": "Persona",
"display_name": "System Prompt", "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", "type": "text",
"help_text": "System prompt prepended to every conversation.", "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": "You are a helpful assistant.", "placeholder": "",
"default": "You are a helpful assistant." "default": ""
}, },
{ {
"key": "MaxTokens", "key": "MaxTokens",
@@ -53,7 +116,7 @@
"display_name": "Rate Limit (requests/minute/user)", "display_name": "Rate Limit (requests/minute/user)",
"type": "number", "type": "number",
"help_text": "Maximum requests per minute per user. 0 = unlimited.", "help_text": "Maximum requests per minute per user. 0 = unlimited.",
"default": 10 "default": 0
}, },
{ {
"key": "AllowedUserIDs", "key": "AllowedUserIDs",
+21 -10
View File
@@ -8,26 +8,36 @@ import (
"github.com/mattermost/mattermost/server/public/plugin" "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.** **@mention the bot in any channel to start a conversation.**
**Commands:** **Commands:**
- @mattermore <prompt> -- Chat with the default model - @matty <prompt> -- Chat with the default model
- @mattermore model <name> <prompt> -- Use a specific model - @matty model <name> <prompt> -- Use a specific model
- @mattermore new -- Reset conversation context - @matty new -- Reset conversation context
- @mattermore model list -- List available models - @matty model list -- List available models
- @mattermore help -- Show this help - @matty help -- Show this help
- @mattermore stop -- End the current conversation - @matty stop -- End the current conversation
You can also use /ai <prompt> as an alternative. You can also use /ai <prompt> as an alternative. /resetmatty clears memory.
The bot responds only when addressed and disengages naturally. 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) { 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")) trigger := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(args.Command), "/ai"))
parts := parseArgs(trigger) 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 { if err := p.conversationStore.Reset(args.UserId, args.ChannelId); err != nil {
return p.response("Failed to reset conversation."), nil return p.response("Failed to reset conversation."), nil
} }
return p.response("Conversation reset. Start fresh with @mattermore <prompt>"), 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": case parts[0] == "model" && len(parts) >= 2 && parts[1] == "list":
return p.handleModelList(args) return p.handleModelList(args)
+5 -1
View File
@@ -10,7 +10,11 @@ import (
type configuration struct { type configuration struct {
OllamaURL string OllamaURL string
DefaultModel string DefaultModel string
SystemPrompt string Persona string
CustomPersona string
Brevity string
RepeatPenalty float64
FrequencyPenalty float64
MaxTokens int MaxTokens int
RateLimitPerMinute int RateLimitPerMinute int
AllowedUserIDs string AllowedUserIDs string
+83
View File
@@ -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)
}
+105 -82
View File
@@ -3,14 +3,13 @@ package main
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/plugin"
) )
const botUsername = "mattermore" const botUsername = "matty"
const botDisplayName = "Mattermore AI" const botDisplayName = "Matty AI"
const botDescription = "AI chat agent powered by Ollama." const botDescription = "AI chat agent powered by Ollama."
// OnActivate is invoked when the plugin is activated. // OnActivate is invoked when the plugin is activated.
@@ -18,8 +17,8 @@ func (p *Plugin) OnActivate() error {
// Register the /ai slash command. // Register the /ai slash command.
if err := p.API.RegisterCommand(&model.Command{ if err := p.API.RegisterCommand(&model.Command{
Trigger: "ai", Trigger: "ai",
DisplayName: "Mattermore", DisplayName: "Matty",
Description: "AI chat agent powered by Ollama.", Description: "AI chat powered by Ollama. @matty",
AutoComplete: true, AutoComplete: true,
AutoCompleteDesc: "Chat with the AI. Usage: /ai <prompt>", AutoCompleteDesc: "Chat with the AI. Usage: /ai <prompt>",
AutoCompleteHint: "<prompt>", AutoCompleteHint: "<prompt>",
@@ -27,17 +26,24 @@ func (p *Plugin) OnActivate() error {
return fmt.Errorf("register command: %w", err) return fmt.Errorf("register command: %w", err)
} }
// Ensure the bot account exists. // Register the /resetmatty command.
bot := &model.Bot{ if err := p.API.RegisterCommand(&model.Command{
Username: botUsername, Trigger: "resetmatty",
DisplayName: botDisplayName, DisplayName: "Reset Matty",
Description: botDescription, 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 { // Ensure the bot account exists — create or re-use.
return fmt.Errorf("create bot: %w", appErr) var err error
p.botUserID, err = p.ensureBot()
if err != nil {
return fmt.Errorf("bot setup: %w", err)
} }
p.botUserID = createdBot.UserId
// Initialise components. // Initialise components.
cfg := p.getConfiguration() cfg := p.getConfiguration()
@@ -52,10 +58,44 @@ func (p *Plugin) OnActivate() error {
p.ollamaClient = NewOllamaClient(cfg.OllamaURL) p.ollamaClient = NewOllamaClient(cfg.OllamaURL)
p.conversationStore = NewConversationStore(p.API) p.conversationStore = NewConversationStore(p.API)
p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute) p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute)
p.engagementEngine = NewEngagementEngine()
return nil 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. // OnDeactivate is invoked when the plugin is deactivated.
func (p *Plugin) OnDeactivate() error { func (p *Plugin) OnDeactivate() error {
// The bot account persists across restarts — it will be re-used // 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 return
} }
// Check if this is a thread reply to a post we made. cfg := p.getConfiguration()
threadOwned := false
if post.RootId != "" {
rootPost, appErr := p.API.GetPost(post.RootId)
if appErr == nil && rootPost.UserId == p.botUserID {
threadOwned = true
}
}
// Check if the bot is @-mentioned in the message. // Check if the bot is @-mentioned in the message.
mentioned := strings.Contains( mentioned := strings.Contains(
@@ -101,12 +134,14 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
"@"+botUsername, "@"+botUsername,
) )
// If not mentioned and not a thread reply, ignore. isActive := p.engagementEngine.IsActive(post.UserId, post.ChannelId)
if !mentioned && !threadOwned {
// If not mentioned and not in an active conversation, ignore.
if !mentioned && !isActive {
return return
} }
// Build the prompt by stripping the @-mention. // Build the prompt.
prompt := post.Message prompt := post.Message
if mentioned { if mentioned {
prompt = strings.TrimSpace(strings.Replace( 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. // Handle stop words before sending to Ollama.
cfg := p.getConfiguration()
if stopWordMatch(prompt, cfg.stopWordSet()) { 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 return
} }
@@ -136,6 +171,11 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
return return
} }
// Wake or refresh the engagement session.
if mentioned {
p.engagementEngine.Wake(post.UserId, post.ChannelId)
}
// Build conversation context. // Build conversation context.
messages, err := p.conversationStore.BuildMessages(post.UserId, post.ChannelId, prompt) messages, err := p.conversationStore.BuildMessages(post.UserId, post.ChannelId, prompt)
if err != nil { if err != nil {
@@ -143,86 +183,69 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
return return
} }
// Create the initial thread post with a placeholder. // Prepend the persona system prompt to set the bot's character.
replyPost := &model.Post{ persona := cfg.CustomPersona
UserId: p.botUserID, if persona == "" {
ChannelId: post.ChannelId, persona = cfg.Persona
Message: "…",
RootId: post.RootId,
} }
if replyPost.RootId == "" { if persona != "" {
replyPost.RootId = post.Id 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) p.API.LogDebug("Chat request", "model", cfg.DefaultModel, "persona", persona, "brevity", cfg.Brevity)
if appErr != nil {
p.API.LogError("Failed to create reply post", "error", appErr.Error())
return
}
// 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{ stream, err := p.ollamaClient.ChatCompletionStream(&ChatRequest{
Model: cfg.DefaultModel, Model: cfg.DefaultModel,
Messages: messages, Messages: messages,
Options: map[string]any{}, Options: opts,
}) })
if err != nil { 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 return
} }
// Read the stream and update the post periodically. var fullResponse strings.Builder
var accumulated strings.Builder for event := range stream {
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 { if event.Error != nil {
p.API.LogError("Stream error", "error", event.Error.Error()) p.API.LogError("Stream error", "error", event.Error.Error())
p.finalisePost(createdPost, accumulated.String()+"\n\n*Error: response truncated*") fullResponse.WriteString("\n\n(Matty is asleep. @matty when you need me.)")
return break
} }
accumulated.WriteString(event.Token) fullResponse.WriteString(event.Token)
if event.Done { if event.Done {
p.finalisePost(createdPost, accumulated.String()) break
return
}
case <-updateTicker.C:
if accumulated.Len() > 0 {
createdPost.Message = accumulated.String()
p.API.UpdatePost(createdPost)
}
} }
} }
p.postReply(post, fullResponse.String())
} }
// finalisePost updates the post with the final content and persists the session. // postReply creates a direct channel post.
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.
func (p *Plugin) postReply(replyTo *model.Post, text string) { func (p *Plugin) postReply(replyTo *model.Post, text string) {
replyPost := &model.Post{ replyPost := &model.Post{
UserId: p.botUserID, UserId: p.botUserID,
ChannelId: replyTo.ChannelId, ChannelId: replyTo.ChannelId,
Message: text, 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 { if _, appErr := p.API.CreatePost(replyPost); appErr != nil {
+1
View File
@@ -17,6 +17,7 @@ type Plugin struct {
ollamaClient *OllamaClient ollamaClient *OllamaClient
conversationStore *ConversationStore conversationStore *ConversationStore
rateLimiter *RateLimiter rateLimiter *RateLimiter
engagementEngine *EngagementEngine
} }
// ServeHTTP allows the plugin to handle HTTP requests. // ServeHTTP allows the plugin to handle HTTP requests.
+6 -2
View File
@@ -19,8 +19,8 @@ type bucket struct {
// NewRateLimiter creates a rate limiter with the given rate (requests/minute). // NewRateLimiter creates a rate limiter with the given rate (requests/minute).
func NewRateLimiter(ratePerMinute int) *RateLimiter { func NewRateLimiter(ratePerMinute int) *RateLimiter {
if ratePerMinute <= 0 { if ratePerMinute < 0 {
ratePerMinute = 10 ratePerMinute = 0
} }
return &RateLimiter{ return &RateLimiter{
users: make(map[string]*bucket), 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. // Allow checks if a request from the given user ID should be allowed.
func (rl *RateLimiter) Allow(userID string) bool { func (rl *RateLimiter) Allow(userID string) bool {
// 0 = unlimited.
if rl.rate == 0 {
return true
}
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
+1 -1
View File
@@ -110,7 +110,7 @@ func (s *ConversationStore) saveSession(session *conversationSession) error {
return err 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 { func stopWordMatch(prompt string, stopWords map[string]bool) bool {
if stopWords == nil { if stopWords == nil {
return false return false