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
+21 -10
View File
@@ -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 <prompt> -- Chat with the default model
- @mattermore model <name> <prompt> -- 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 <prompt> -- Chat with the default model
- @matty model <name> <prompt> -- 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 <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.
`
// 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 <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":
return p.handleModelList(args)
+5 -1
View File
@@ -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
+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)
}
+109 -86
View File
@@ -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 <prompt>",
AutoCompleteHint: "<prompt>",
@@ -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 {
+6 -5
View File
@@ -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.
+6 -2
View File
@@ -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()
+1 -1
View File
@@ -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