Add EngagementEngine, persona system, dropdowns, penalties, /resetmatty, debug logging
This commit is contained in:
+21
-10
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user