Add /ai persona command, debug logging toggle, context trimming, persona override

This commit is contained in:
2026-06-16 21:11:44 +02:00
parent 31a81fbfaa
commit f038316615
7 changed files with 254 additions and 44 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
# make clean — Remove build artifacts # make clean — Remove build artifacts
PLUGIN_ID := com.forkless.mattermore PLUGIN_ID := com.forkless.mattermore
PLUGIN_VERSION := 0.1.0 PLUGIN_VERSION := 0.1.9
DIST_DIR := dist DIST_DIR := dist
SERVER_DIR := server SERVER_DIR := server
BUNDLE_NAME := $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz BUNDLE_NAME := $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz
+28 -21
View File
@@ -2,7 +2,7 @@
"id": "com.forkless.mattermore", "id": "com.forkless.mattermore",
"name": "Matty", "name": "Matty",
"description": "AI chat agent powered by Ollama.", "description": "AI chat agent powered by Ollama.",
"version": "0.1.0", "version": "0.1.9",
"min_server_version": "11.6.1", "min_server_version": "11.6.1",
"server": { "server": {
"executables": { "executables": {
@@ -44,27 +44,27 @@
"display_name": "Bot Persona", "display_name": "Bot Persona",
"type": "dropdown", "type": "dropdown",
"help_text": "The bot's character. Pick a persona or set a custom one in the config file.", "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.", "default": "assistant",
"options": [ "options": [
{ "display_name": "D&D dungeon master", "value": "a Dungeons & Dragons dungeon master. Epic narrative, roll for initiative." }, { "display_name": "D&D dungeon master", "value": "dnd" },
{ "display_name": "Donald Trump", "value": "Donald Trump. Boastful, hyperbolic, third-person. Think: rally speech, not catchphrase list. Keep it to 2 sentences." }, { "display_name": "Donald Trump", "value": "trump" },
{ "display_name": "Drunken Jeanine Pirro", "value": "a drunk Jeanine Pirro. Slur your words, repeat yourself, be loud and uncensored." }, { "display_name": "Drunken Jeanine Pirro", "value": "pirro" },
{ "display_name": "French mime", "value": "a French mime. Respond with dramatic pauses and gestures described in asterisks. Minimal words." }, { "display_name": "French mime", "value": "mime" },
{ "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": "George Carlin", "value": "carlin" },
{ "display_name": "Gordon Ramsay (code review)", "value": "Gordon Ramsay but for code reviews. Aggressive chef. YELL. Brief." }, { "display_name": "Gordon Ramsay (code review)", "value": "ramsay" },
{ "display_name": "Grumpy sysadmin", "value": "a grumpy sysadmin who thinks every question is stupid. Be sarcastic, brief, and annoyed." }, { "display_name": "Grumpy sysadmin", "value": "sysadmin" },
{ "display_name": "Helpful assistant (concise)", "value": "a helpful assistant. Be concise, 2-3 sentences max, never repeat yourself." }, { "display_name": "Helpful assistant (concise)", "value": "assistant" },
{ "display_name": "Infomercial host (1980s)", "value": "a 1980s TV infomercial host. Over-enthusiastic, loud, BUT WAIT THERE'S MORE." }, { "display_name": "Infomercial host (1980s)", "value": "infomercial" },
{ "display_name": "Medieval court jester", "value": "a medieval court jester who mocks everyone. Old-timey insults, brief." }, { "display_name": "Medieval court jester", "value": "jester" },
{ "display_name": "Motivational speaker", "value": "a motivational speaker who turns everything into a life lesson. Over-the-top positive but concise." }, { "display_name": "Motivational speaker", "value": "motivational" },
{ "display_name": "Noir detective", "value": "a noir detective who answers every question with another question. Brooding, cryptic, short." }, { "display_name": "Noir detective", "value": "noir" },
{ "display_name": "Overly polite Canadian", "value": "an overly polite Canadian who apologises excessively. Sorry, eh, keep it short." }, { "display_name": "Overly polite Canadian", "value": "canadian" },
{ "display_name": "Philosophical overthinker", "value": "a philosopher who overcomplicates everything. Pseudo-intellectual, wordy is the point." }, { "display_name": "Philosophical overthinker", "value": "philosopher" },
{ "display_name": "Pirate captain", "value": "a pirate captain. Use pirate slang, be theatrical, keep responses short." }, { "display_name": "Pirate captain", "value": "pirate" },
{ "display_name": "Shakespearean author", "value": "a Shakespearean author. Respond in eloquent old English, but keep it brief." }, { "display_name": "Shakespearean author", "value": "shakespeare" },
{ "display_name": "Surfer dude", "value": "a surfer dude explaining complex topics badly. Chill, inaccurate, gnarly." }, { "display_name": "Surfer dude", "value": "surfer" },
{ "display_name": "Tech support scammer (2008)", "value": "a tech support scammer from 2008. Alarmist, pushy, uses ALL CAPS occasionally." }, { "display_name": "Tech support scammer (2008)", "value": "scammer" },
{ "display_name": "Tired parent", "value": "a tired parent who's given up on life. Exhausted, relatable, monosyllabic when possible." } { "display_name": "Tired parent", "value": "tiredparent" }
] ]
}, },
{ {
@@ -96,6 +96,13 @@
{ "display_name": "Unlimited", "value": "0" } { "display_name": "Unlimited", "value": "0" }
] ]
}, },
{
"key": "DebugLogging",
"display_name": "Debug Logging",
"type": "bool",
"help_text": "Enable detailed request/response logging in the server logs.",
"default": false
},
{ {
"key": "CustomPersona", "key": "CustomPersona",
"display_name": "Custom Persona (overrides dropdown)", "display_name": "Custom Persona (overrides dropdown)",
+84
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/model"
@@ -16,6 +17,7 @@ const helpText = `### Matty -- AI Chat Agent
- @matty <prompt> -- Chat with the default model - @matty <prompt> -- Chat with the default model
- @matty model <name> <prompt> -- Use a specific model - @matty model <name> <prompt> -- Use a specific model
- @matty persona <name> -- Switch persona (e.g. "trump", "carlin", "sysadmin")
- @matty new -- Reset conversation context - @matty new -- Reset conversation context
- @matty model list -- List available models - @matty model list -- List available models
- @matty help -- Show this help - @matty help -- Show this help
@@ -52,6 +54,15 @@ func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*mo
p.engagementEngine.Sleep(args.UserId, args.ChannelId) p.engagementEngine.Sleep(args.UserId, args.ChannelId)
return p.response("Conversation reset. @matty to start again."), nil return p.response("Conversation reset. @matty to start again."), nil
case parts[0] == "persona" && len(parts) == 1:
return p.handlePersonaShow(args)
case parts[0] == "persona" && len(parts) >= 2 && parts[1] == "list":
return p.handlePersonaList(args)
case parts[0] == "persona" && len(parts) >= 2:
return p.handlePersonaSwitch(args, parts[1])
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)
@@ -113,6 +124,57 @@ func (p *Plugin) handlePrompt(args *model.CommandArgs, modelName, prompt string)
}, nil }, nil
} }
// handlePersonaShow shows the currently active persona.
func (p *Plugin) handlePersonaShow(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
override := p.GetPersonaOverride(args.UserId, args.ChannelId)
if override != "" {
return p.response("Active persona: " + override + "."), nil
}
cfg := p.getConfiguration()
if cfg.CustomPersona != "" {
return p.response("Active persona: custom."), nil
}
return p.response("Active persona: " + cfg.Persona + "."), nil
}
// handlePersonaList lists available personas with descriptions.
func (p *Plugin) handlePersonaList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
return p.response(p.listPersonas()), nil
}
// handlePersonaSwitch persists a persona override for the user+channel.
func (p *Plugin) handlePersonaSwitch(args *model.CommandArgs, personaID string) (*model.CommandResponse, *model.AppError) {
resolved := resolvePersona(personaID)
if resolved == "" || resolved == personaLibrary["assistant"] {
return p.response("Unknown persona. Available: " + p.listPersonas()), nil
}
if err := p.SavePersonaOverride(args.UserId, args.ChannelId, personaID); err != nil {
return p.response("Failed to save persona: " + err.Error()), nil
}
return p.response("Persona switched to: " + personaID + ". @matty to test."), nil
}
// listPersonas returns a formatted list of persona IDs with short descriptions.
func (p *Plugin) listPersonas() string {
ids := make([]string, 0, len(personaLibrary))
for id := range personaLibrary {
ids = append(ids, id)
}
sort.Strings(ids)
var b strings.Builder
b.WriteString("Available personas:\n")
for _, id := range ids {
desc := personaLibrary[id]
// Truncate to ~60 chars for a compact preview.
if len(desc) > 60 {
desc = desc[:57] + "..."
}
b.WriteString(fmt.Sprintf(" %-12s %s\n", id, desc))
}
b.WriteString("Use /ai persona <name> to switch.")
return b.String()
}
// handleModelList queries Ollama for available models and returns them. // handleModelList queries Ollama for available models and returns them.
func (p *Plugin) handleModelList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { func (p *Plugin) handleModelList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
models, err := p.ollamaClient.ListModels() models, err := p.ollamaClient.ListModels()
@@ -127,6 +189,28 @@ func (p *Plugin) handleModelList(args *model.CommandArgs) (*model.CommandRespons
return p.response(b.String()), nil return p.response(b.String()), nil
} }
// personaOverrideKey returns the KV store key for a user+channel persona override.
func personaOverrideKey(userID, channelID string) string {
return "persona_" + userID + "_" + channelID
}
// SavePersonaOverride persists a persona override to the KV store.
func (p *Plugin) SavePersonaOverride(userID, channelID, personaID string) error {
if appErr := p.API.KVSet(personaOverrideKey(userID, channelID), []byte(personaID)); appErr != nil {
return fmt.Errorf("kvset: %w", appErr)
}
return nil
}
// GetPersonaOverride returns the persisted persona override for a user+channel.
func (p *Plugin) GetPersonaOverride(userID, channelID string) string {
data, appErr := p.API.KVGet(personaOverrideKey(userID, channelID))
if appErr != nil || data == nil {
return ""
}
return string(data)
}
// parseArgs splits a command string into tokens. // parseArgs splits a command string into tokens.
func parseArgs(input string) []string { func parseArgs(input string) []string {
return strings.Fields(input) return strings.Fields(input)
+14 -2
View File
@@ -13,12 +13,13 @@ type configuration struct {
Persona string Persona string
CustomPersona string CustomPersona string
Brevity string Brevity string
RepeatPenalty float64 RepeatPenalty int
FrequencyPenalty float64 FrequencyPenalty int
MaxTokens int MaxTokens int
RateLimitPerMinute int RateLimitPerMinute int
AllowedUserIDs string AllowedUserIDs string
StopWords string StopWords string
DebugLogging bool
} }
// getConfiguration returns the current configuration, safe for concurrent use. // getConfiguration returns the current configuration, safe for concurrent use.
@@ -76,13 +77,24 @@ func (p *Plugin) OnConfigurationChange() error {
// Load from the System Console settings. // Load from the System Console settings.
if err := p.API.LoadPluginConfiguration(&c); err != nil { if err := p.API.LoadPluginConfiguration(&c); err != nil {
p.API.LogError("Config load failed", "error", err.Error())
return fmt.Errorf("failed to load plugin configuration: %w", err) return fmt.Errorf("failed to load plugin configuration: %w", err)
} }
if c.DebugLogging {
p.API.LogInfo("Config loaded", "model", c.DefaultModel, "persona", c.Persona, "brevity", c.Brevity, "repeat", c.RepeatPenalty, "freq", c.FrequencyPenalty, "tokens", c.MaxTokens)
}
if err := p.setConfiguration(&c); err != nil { if err := p.setConfiguration(&c); err != nil {
p.API.LogError("Config validate failed", "error", err.Error())
return fmt.Errorf("failed to set configuration: %w", err) return fmt.Errorf("failed to set configuration: %w", err)
} }
// Pass the debug logging flag to the Ollama client.
if p.ollamaClient != nil {
p.ollamaClient.SetLogging(c.DebugLogging)
}
return nil return nil
} }
+65 -17
View File
@@ -55,7 +55,7 @@ func (p *Plugin) OnActivate() error {
cfg = p.getConfiguration() cfg = p.getConfiguration()
} }
p.ollamaClient = NewOllamaClient(cfg.OllamaURL) p.ollamaClient = NewOllamaClient(cfg.OllamaURL, p.API.LogInfo)
p.conversationStore = NewConversationStore(p.API) p.conversationStore = NewConversationStore(p.API)
p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute) p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute)
p.engagementEngine = NewEngagementEngine() p.engagementEngine = NewEngagementEngine()
@@ -183,30 +183,78 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
return return
} }
// Prepend the persona system prompt to set the bot's character. // Limit conversation history based on brevity — fewer sentences = less context
persona := cfg.CustomPersona // to prevent the model re-addressing old messages.
if persona == "" {
persona = cfg.Persona
}
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" { if cfg.Brevity != "" && cfg.Brevity != "0" {
systemContent += " Reply in " + cfg.Brevity + " sentences or fewer." brevity := 0
fmt.Sscanf(cfg.Brevity, "%d", &brevity)
if brevity > 0 && len(messages) > 2 {
// Keep: system prompt (added later) + (brevity * 2) lines of history + current prompt
keep := brevity * 2
if keep < 2 {
keep = 2 // always keep at least the current exchange
}
if keep < len(messages) {
messages = messages[len(messages)-keep:]
} }
systemMsg := ChatMessage{
Role: "system",
Content: systemContent,
} }
messages = append([]ChatMessage{systemMsg}, messages...)
} }
p.API.LogDebug("Chat request", "model", cfg.DefaultModel, "persona", persona, "brevity", cfg.Brevity) // Prepend the persona system prompt to set the bot's character.
// Priority: KV override > CustomPersona config > Persona dropdown config
persona := p.GetPersonaOverride(post.UserId, post.ChannelId)
if persona == "" {
persona = cfg.CustomPersona
}
if persona == "" {
persona = resolvePersona(cfg.Persona)
}
systemPrompt := ""
if persona != "" {
systemPrompt = "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."
if cfg.Brevity != "" && cfg.Brevity != "0" {
systemPrompt += " Reply in " + cfg.Brevity + " sentences or fewer."
}
messages = append([]ChatMessage{{
Role: "system",
Content: systemPrompt,
}}, messages...)
}
// Calculate the resolved token cap.
maxTokens := cfg.MaxTokens
if cfg.Brevity != "" && cfg.Brevity != "0" {
brevity := 0
fmt.Sscanf(cfg.Brevity, "%d", &brevity)
if brevity > 0 {
suggested := 100 + brevity*75
if maxTokens == 0 || suggested < maxTokens {
maxTokens = suggested
}
}
}
if cfg.DebugLogging {
p.API.LogInfo("Chat request",
"model", cfg.DefaultModel,
"ollama_url", cfg.OllamaURL,
"persona", cfg.Persona,
"custom_persona", cfg.CustomPersona,
"brevity", cfg.Brevity,
"max_tokens_config", cfg.MaxTokens,
"max_tokens_resolved", maxTokens,
"repeat", cfg.RepeatPenalty,
"freq", cfg.FrequencyPenalty,
"rate_limit", cfg.RateLimitPerMinute,
"allowed_users", cfg.AllowedUserIDs,
"stop_words", cfg.StopWords,
"system", systemPrompt)
}
// Stream the full response from Ollama, then post once. // Stream the full response from Ollama, then post once.
opts := map[string]any{} opts := map[string]any{}
if cfg.MaxTokens > 0 { if maxTokens > 0 {
opts["num_predict"] = cfg.MaxTokens opts["num_predict"] = maxTokens
} }
if cfg.RepeatPenalty > 0 { if cfg.RepeatPenalty > 0 {
opts["repeat_penalty"] = float64(cfg.RepeatPenalty) / 10.0 opts["repeat_penalty"] = float64(cfg.RepeatPenalty) / 10.0
+22 -1
View File
@@ -9,19 +9,37 @@ import (
"time" "time"
) )
// logFunc is a callback for logging (hooked to the Mattermost plugin API).
type logFunc func(msg string, keyValuePairs ...any)
// OllamaClient communicates with the Ollama API. // OllamaClient communicates with the Ollama API.
type OllamaClient struct { type OllamaClient struct {
baseURL string baseURL string
httpClient *http.Client httpClient *http.Client
logInfo logFunc
logEnabled bool
}
// SetLogging enables or disables request/response logging.
func (c *OllamaClient) SetLogging(enabled bool) {
c.logEnabled = enabled
} }
// NewOllamaClient creates a new client for the given Ollama server URL. // NewOllamaClient creates a new client for the given Ollama server URL.
func NewOllamaClient(baseURL string) *OllamaClient { func NewOllamaClient(baseURL string, logInfo logFunc) *OllamaClient {
return &OllamaClient{ return &OllamaClient{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
logInfo: logInfo,
}
}
// logRequest logs the full Ollama API request for debugging.
func (c *OllamaClient) logRequest(model, body string) {
if c.logEnabled && c.logInfo != nil {
c.logInfo("Ollama API call", "model", model, "url", c.baseURL+"/api/chat", "body", body)
} }
} }
@@ -113,6 +131,9 @@ func (c *OllamaClient) ChatCompletionStream(req *ChatRequest) (<-chan StreamEven
return nil, fmt.Errorf("marshal request: %w", err) return nil, fmt.Errorf("marshal request: %w", err)
} }
// Log the full API request for debugging.
c.logRequest(req.Model, string(body))
httpReq, err := http.NewRequest("POST", c.baseURL+"/api/chat", strings.NewReader(string(body))) httpReq, err := http.NewRequest("POST", c.baseURL+"/api/chat", strings.NewReader(string(body)))
if err != nil { if err != nil {
return nil, fmt.Errorf("create request: %w", err) return nil, fmt.Errorf("create request: %w", err)
+38
View File
@@ -0,0 +1,38 @@
package main
// personaLibrary maps stable IDs to persona descriptions.
// The IDs never change — only the descriptions get refined.
var personaLibrary = map[string]string{
"assistant": "a helpful assistant. Be concise, 2-3 sentences max, never repeat yourself.",
"carlin": "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.",
"canadian": "an overly polite Canadian who apologises excessively. Sorry, eh, keep it short.",
"dnd": "a Dungeons & Dragons dungeon master. Epic narrative, roll for initiative.",
"infomercial": "a 1980s TV infomercial host. Over-enthusiastic, loud, ALWAYS acting like you're pitching a product. Be concise — one short pitch per response.",
"jester": "a medieval court jester who mocks everyone. Old-timey insults, brief.",
"mime": "a French mime. Respond with dramatic pauses and gestures described in asterisks. Minimal words.",
"motivational": "a motivational speaker who turns everything into a life lesson. Over-the-top positive but concise.",
"noir": "a noir detective who answers every question with another question. Brooding, cryptic, short.",
"philosopher": "a philosopher who overcomplicates everything. Pseudo-intellectual, wordy is the point.",
"pirate": "a pirate captain. Use pirate slang, be theatrical, keep responses short.",
"pirro": "a drunk Jeanine Pirro. Slur your words, repeat yourself, be loud and uncensored.",
"ramsay": "Gordon Ramsay but for code reviews. Aggressive chef. YELL. Brief.",
"shakespeare": "a Shakespearean author. Respond in eloquent old English, but keep it brief.",
"surfer": "a surfer dude explaining complex topics badly. Chill, inaccurate, gnarly.",
"sysadmin": "a grumpy sysadmin who thinks every question is stupid. Be sarcastic, brief, and annoyed.",
"tiredparent": "a tired parent who's given up on life. Exhausted, relatable, monosyllabic when possible.",
"trump": "Donald Trump. Boastful, hyperbolic, third-person, rally speech style, vocabulary of a 12 year old. Use his usual cadence naturally. Keep it to 2 sentences.",
"scammer": "a tech support scammer from 2008. Alarmist, pushy, uses ALL CAPS occasionally.",
}
// resolvePersona takes a value from the dropdown (stable ID or legacy full text)
// and returns the full persona description. Falls back to the default if unknown.
func resolvePersona(value string) string {
if p, ok := personaLibrary[value]; ok {
return p
}
// If it's already a full description (e.g. old saved config), pass through.
if value != "" {
return value
}
return personaLibrary["assistant"]
}