diff --git a/Makefile b/Makefile index ad52419..277e056 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # make clean — Remove build artifacts PLUGIN_ID := com.forkless.mattermore -PLUGIN_VERSION := 0.1.0 +PLUGIN_VERSION := 0.1.9 DIST_DIR := dist SERVER_DIR := server BUNDLE_NAME := $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz diff --git a/plugin.json b/plugin.json index 000fbe6..eba4b17 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "id": "com.forkless.mattermore", "name": "Matty", "description": "AI chat agent powered by Ollama.", - "version": "0.1.0", + "version": "0.1.9", "min_server_version": "11.6.1", "server": { "executables": { @@ -44,27 +44,27 @@ "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.", + "default": "assistant", "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." } + { "display_name": "D&D dungeon master", "value": "dnd" }, + { "display_name": "Donald Trump", "value": "trump" }, + { "display_name": "Drunken Jeanine Pirro", "value": "pirro" }, + { "display_name": "French mime", "value": "mime" }, + { "display_name": "George Carlin", "value": "carlin" }, + { "display_name": "Gordon Ramsay (code review)", "value": "ramsay" }, + { "display_name": "Grumpy sysadmin", "value": "sysadmin" }, + { "display_name": "Helpful assistant (concise)", "value": "assistant" }, + { "display_name": "Infomercial host (1980s)", "value": "infomercial" }, + { "display_name": "Medieval court jester", "value": "jester" }, + { "display_name": "Motivational speaker", "value": "motivational" }, + { "display_name": "Noir detective", "value": "noir" }, + { "display_name": "Overly polite Canadian", "value": "canadian" }, + { "display_name": "Philosophical overthinker", "value": "philosopher" }, + { "display_name": "Pirate captain", "value": "pirate" }, + { "display_name": "Shakespearean author", "value": "shakespeare" }, + { "display_name": "Surfer dude", "value": "surfer" }, + { "display_name": "Tech support scammer (2008)", "value": "scammer" }, + { "display_name": "Tired parent", "value": "tiredparent" } ] }, { @@ -96,6 +96,13 @@ { "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", "display_name": "Custom Persona (overrides dropdown)", diff --git a/server/command.go b/server/command.go index f7701ce..77dc7af 100644 --- a/server/command.go +++ b/server/command.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "sort" "strings" "github.com/mattermost/mattermost/server/public/model" @@ -16,6 +17,7 @@ const helpText = `### Matty -- AI Chat Agent - @matty -- Chat with the default model - @matty model -- Use a specific model +- @matty persona -- Switch persona (e.g. "trump", "carlin", "sysadmin") - @matty new -- Reset conversation context - @matty model list -- List available models - @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) 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": return p.handleModelList(args) @@ -113,6 +124,57 @@ func (p *Plugin) handlePrompt(args *model.CommandArgs, modelName, prompt string) }, 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 to switch.") + return b.String() +} + // handleModelList queries Ollama for available models and returns them. func (p *Plugin) handleModelList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { models, err := p.ollamaClient.ListModels() @@ -127,6 +189,28 @@ func (p *Plugin) handleModelList(args *model.CommandArgs) (*model.CommandRespons 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. func parseArgs(input string) []string { return strings.Fields(input) diff --git a/server/configuration.go b/server/configuration.go index 81e8dd4..2e8e120 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -13,12 +13,13 @@ type configuration struct { Persona string CustomPersona string Brevity string - RepeatPenalty float64 - FrequencyPenalty float64 + RepeatPenalty int + FrequencyPenalty int MaxTokens int RateLimitPerMinute int AllowedUserIDs string StopWords string + DebugLogging bool } // getConfiguration returns the current configuration, safe for concurrent use. @@ -76,13 +77,24 @@ func (p *Plugin) OnConfigurationChange() error { // Load from the System Console settings. 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) } + 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 { + p.API.LogError("Config validate failed", "error", err.Error()) 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 } diff --git a/server/hooks.go b/server/hooks.go index b697f3b..f751e03 100644 --- a/server/hooks.go +++ b/server/hooks.go @@ -55,7 +55,7 @@ func (p *Plugin) OnActivate() error { cfg = p.getConfiguration() } - p.ollamaClient = NewOllamaClient(cfg.OllamaURL) + p.ollamaClient = NewOllamaClient(cfg.OllamaURL, p.API.LogInfo) p.conversationStore = NewConversationStore(p.API) p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute) p.engagementEngine = NewEngagementEngine() @@ -183,30 +183,78 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) { return } - // Prepend the persona system prompt to set the bot's character. - persona := cfg.CustomPersona - 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" { - systemContent += " Reply in " + cfg.Brevity + " sentences or fewer." + // Limit conversation history based on brevity — fewer sentences = less context + // to prevent the model re-addressing old messages. + if cfg.Brevity != "" && cfg.Brevity != "0" { + 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. opts := map[string]any{} - if cfg.MaxTokens > 0 { - opts["num_predict"] = cfg.MaxTokens + if maxTokens > 0 { + opts["num_predict"] = maxTokens } if cfg.RepeatPenalty > 0 { opts["repeat_penalty"] = float64(cfg.RepeatPenalty) / 10.0 diff --git a/server/ollama.go b/server/ollama.go index ea0d79c..582b1a8 100644 --- a/server/ollama.go +++ b/server/ollama.go @@ -9,19 +9,37 @@ import ( "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. type OllamaClient struct { baseURL string 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. -func NewOllamaClient(baseURL string) *OllamaClient { +func NewOllamaClient(baseURL string, logInfo logFunc) *OllamaClient { return &OllamaClient{ baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{ 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) } + // 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))) if err != nil { return nil, fmt.Errorf("create request: %w", err) diff --git a/server/persona.go b/server/persona.go new file mode 100644 index 0000000..82934c2 --- /dev/null +++ b/server/persona.go @@ -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"] +}