From 72d4821a263680ce41235e16268a7941ca15821e Mon Sep 17 00:00:00 2001 From: Fork Less Date: Tue, 16 Jun 2026 21:57:57 +0200 Subject: [PATCH] Rewrite DESIGN.md to reflect current v0.1.16 state --- DESIGN.md | 750 ++++++++++++++++++++++++------------------------------ 1 file changed, 330 insertions(+), 420 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 259e253..2039c5f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,10 +1,9 @@ -# Design: Mattermore — Mattermost AI Chat Agent for Ollama +# Design: Matty — forkless' annoying Mattermost sidekick -> **Status:** Draft / Pre-implementation +> **Status:** v0.1.16 — Implemented and deployed > **Last updated:** 2026-06-16 -> **Assumed direction:** AI chat agent plugin connecting to a remote Ollama instance. -> **Open questions are marked** `TODO(design):` throughout. This is a living template — change -> assumptions as the product direction firms up. +> **Bot username:** @matty +> **Default model:** dolphin-llama3:latest (configurable in System Console) --- @@ -12,12 +11,16 @@ ### 1.1 What It Is -Mattermore is a Mattermost (Community Edition) plugin that lets users chat +Matty is a Mattermost (Community Edition) plugin that lets users chat with remote LLMs through an Ollama backend. Users address the bot by -**@-mentioning** it in any channel (e.g. `@mattermore write a poem`). The +**@-mentioning** it in any channel (e.g. `@matty write a poem`). The bot responds **only when addressed** and disengages naturally — it never -reads every message in the room. A slash command (`/ai`) remains available -as an alternative entry point. +reads every message in the room. Once addressed, the user enters an +**active session** where follow-up messages continue the conversation +without needing to re-@mention. Sessions end on stop words ("thanks", +"bye"), explicit `@matty stop`, or 5 minutes of inactivity. + +A slash command (`/ai`) remains available as an alternative entry point. ### 1.2 What It Is Not (Explicit Out-of-Scope) @@ -26,7 +29,9 @@ as an alternative entry point. | Hosting its own LLM inference | Ollama is the sole backend; the plugin is a thin proxy | | Multi-backend abstraction (OpenAI, Anthropic, etc.) | If needed later, abstract behind an `LLMProvider` interface — not now | | Training / fine-tuning | Ollama handles that | -| **Channel whisper mode** — replying to every message in a channel | The agent only activates on @-mention or thread reply. It never reads unprompted messages. | +| **Channel whisper mode** (replying to every message) | The agent only activates on @-mention or active session. It never reads unprompted messages. | +| Bot account management | Plugin creates the bot account automatically via `API.CreateBot()` | +| Webapp UI | No frontend — @-mention + `/ai` covers all interaction | ### 1.3 Target Audience @@ -46,7 +51,7 @@ as an alternative entry point. │ Mattermost Server (Community Edition) │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ -│ │ Mattermore Plugin │ │ +│ │ Matty Plugin │ │ │ │ │ │ │ │ ┌──────────────────┐ ┌────────────────────────┐ │ │ │ │ │ MessageHasBeen │──▶│ Plugin (Go) │ │ │ @@ -58,85 +63,93 @@ as an alternative entry point. │ │ └──────────────────┘ │ │ ConversationStore│ │ │ │ │ │ ┌──────────────────┐ │ └──────────────────┘ │ │ │ │ │ │ EngagementEngine │ │ ┌──────────────────┐ │ │ │ -│ │ │ wake / sleep │──▶│ │ RateLimiter │ │ │ │ +│ │ │ sleep / active │──▶│ │ RateLimiter │ │ │ │ │ │ └──────────────────┘ │ └──────────────────┘ │ │ │ │ │ ┌──────────────────┐ │ ┌──────────────────┐ │ │ │ -│ │ │ Bot Account │ │ │ Config (KV) │ │ │ │ -│ │ │ (auto-created) │ │ └──────────────────┘ │ │ │ +│ │ │ PersonaLibrary │ │ │ Config (KV) │ │ │ │ +│ │ │ resolvePersona() │ │ └──────────────────┘ │ │ │ +│ │ └──────────────────┘ │ │ │ │ +│ │ ┌──────────────────┐ │ │ │ │ +│ │ │ Bot Account │ │ │ │ │ +│ │ │ (auto-created) │ │ │ │ │ │ │ └──────────────────┘ │ │ │ │ │ │ ┌──────────────────┐ │ │ │ │ │ │ │ Webapp (none) │ │ │ │ │ │ │ └──────────────────┘ │ │ │ │ │ └──────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ KV Store (per-plugin) │ │ +│ │ • ctx_{user}_{channel} — conversation history │ │ +│ │ • persona_{user}_{ch} — persona override │ │ +│ └──────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ ``` -### 2.2 Request Flow (happy path) +### 2.2 Request Flow (happy path — @-mention) ``` -User types "@mattermore write a release note for v2.1" +User types "@matty write a release note for v2.1" │ ▼ -Mattermore MessageHasBeenPosted fires +MessageHasBeenPosted fires │ ▼ EngagementEngine.Wake(userId, channelId) → mark session Active │ ▼ -@-mention detected → strip "@mattermore " prefix from prompt +@-mention detected → strip "@matty " prefix → "write a release note for v2.1" │ ▼ -RateLimiter.Allow(userId) → 200 or 429 +RateLimiter.Allow(userId) → 200 │ ▼ -ConversationStore.GetSession(userId, channelId, threadId) → previous messages for context +Check stop words → no match │ ▼ -OllamaClient.ChatCompletion(messages, stream=true, model=defaultModel) +Check persona override (KV store) → resolvePersona() → full description │ ▼ -Ollama returns ndjson stream → plugin creates root post with live-updating thread +ConversationStore.BuildMessages(userId, channelId, prompt) → up to 10 turns │ ▼ -Post created in channel → user sees response in thread +Trim context based on Brevity (e.g. brevity=1 → keep only current message) │ ▼ -ConversationStore.Append(userId, channelId, threadId, userMsg, assistantMsg) +Prepend system prompt: "You are {persona}. Respond in character. ... Reply in {N} sentences." + │ + ▼ +Calculate token cap: maxTokens = auto from brevity, or manual override + │ + ▼ +OllamaClient.ChatCompletionStream(model, messages, options) + │ + ▼ +Ollama returns ndjson stream → accumulate all tokens + │ + ▼ +Create single post with full response (not a thread reply, not live-updated) ``` -#### Thread Reply Flow (continues the conversation) +### 2.3 Engagement Session Flow (follow-up without @-mention) ``` -User replies in thread → "Can you make it more formal?" - │ - ▼ -Mattermore MessageHasBeenPosted fires (same thread as bot post) - │ - ▼ -EngagementEngine.IsActive(userId, threadId) → true - │ - ▼ -Not an @-mention, but active thread → treat as continuation - │ - ▼ -ConversationStore.GetSession(userId, channelId, threadId) → full context so far - │ - ▼ -OllamaClient.ChatCompletion(...) → stream response into same thread - │ - ▼ -ConversationStore.Append(userId, channelId, userMsg, assistantMsg) +User: "@matty hello" → bot responds, session starts +User: "tell me a joke" (no @) → IsActive() → true → bot responds +User: "what about weather?" (no @) → IsActive() → true → bot responds +User: "thanks" → stopWordMatch → Sleep() → bot signs off +User: "hello again" (no @, 10 min later)→ IsActive() → false → ignored +User: "@matty hello again" → Wake() → bot responds, new session ``` -### 2.3 Error Flow +### 2.4 Error Flow | Failure point | Behaviour | |---|---| -| Ollama unreachable | Ephemeral post: "Ollama at unreachable. Check configuration." | -| Rate limit hit | Ephemeral post: "Rate limit exceeded. Try again in N seconds." | -| Invalid model name | Ephemeral post: "Model 'xyz' not found on Ollama server." | -| Timeout (>30 s) | Abort stream, post partial response + "Response truncated (timeout)." | -| Input too long | Reject with context window limit message; suggest `@mattermore new` to reset. | +| Ollama unreachable | Post: "Matty is asleep. @matty when you need me." | +| Rate limit hit | Post: "Rate limit exceeded. Please wait before sending another request." | +| Invalid persona | Ephemeral: "Unknown persona. Available: ..." | +| Timeout (>30 s) | Post truncated with "(Matty is asleep...)" | --- @@ -144,89 +157,27 @@ ConversationStore.Append(userId, channelId, userMsg, assistantMsg) ### 3.1 `plugin.json` -```json -{ - "id": "com.forkless.mattermore", - "name": "Mattermore", - "description": "AI chat agent powered by Ollama.", - "version": "0.1.0", - "server": { - "executables": { - "linux-amd64": "server/dist/plugin-linux-amd64", - "linux-arm64": "server/dist/plugin-linux-arm64", - "darwin-amd64": "server/dist/plugin-darwin-amd64" - } - }, - "webapp": {}, - "settings_schema": { - "header": "Configure the connection to your Ollama instance.", - "footer": "", - "settings": [ - { - "key": "OllamaURL", - "display_name": "Ollama Server URL", - "type": "text", - "help_text": "Base URL of the Ollama API, e.g. http://10.0.0.5:11434", - "placeholder": "http://localhost:11434", - "default": "http://localhost:11434" - }, - { - "key": "DefaultModel", - "display_name": "Default Model", - "type": "text", - "help_text": "Model name to use when none is specified.", - "placeholder": "llama3:latest", - "default": "llama3:latest" - }, - { - "key": "SystemPrompt", - "display_name": "System Prompt", - "type": "text", - "help_text": "System prompt prepended to every conversation.", - "placeholder": "You are a helpful assistant.", - "default": "You are a helpful assistant." - }, - { - "key": "MaxTokens", - "display_name": "Max Response Tokens", - "type": "number", - "help_text": "Maximum tokens per response (0 = model default).", - "default": 2048 - }, - { - "key": "RateLimitPerMinute", - "display_name": "Rate Limit (requests/minute/user)", - "type": "number", - "help_text": "Maximum requests per minute per user. 0 = unlimited.", - "default": 10 - }, - { - "key": "AllowedUserIDs", - "display_name": "Allowed User IDs (comma-separated)", - "type": "text", - "help_text": "Restrict to specific user IDs. Empty = all users.", - "default": "" - }, - { - "key": "StopWords", - "display_name": "Stop Words (comma-separated)", - "type": "text", - "help_text": "When the user says one of these, the bot responds briefly and disengages.", - "placeholder": "thanks, thank you, bye, goodbye, that's all, done", - "default": "thanks, thank you, bye, goodbye, see you, that's all, that's it, done, all done, stop, quit, end" - } - ] - } -} -``` +Current settings (v0.1.16): -`TODO(design):` Should `AllowedUserIDs` be a team/channel whitelist instead? -For now, keep user-level granularity — easy to widen later. +| Key | Type | Default | Purpose | +|---|---|---|---| +| `OllamaURL` | text | `http://localhost:11434` | Ollama server endpoint | +| `DefaultModel` | dropdown | `dolphin-llama3:latest` | Model for chat | +| `Persona` | dropdown | `assistant` | Character for the bot | +| `RepeatPenalty` | number | `11` (1.1) | Penalise repeated tokens | +| `FrequencyPenalty` | number | `1` (0.1) | Penalise frequent phrases | +| `Brevity` | dropdown | `3` | Max sentences | +| `DebugLogging` | bool | `false` | Enable verbose logging | +| `CustomPersona` | text | `""` | Override dropdown persona | +| `MaxTokens` | number | `0` | Manual token cap (0 = auto from Brevity) | +| `RateLimitPerMinute` | number | `0` | Requests/min/user (0 = unlimited) | +| `AllowedUserIDs` | text | `""` | Restrict to specific users | +| `StopWords` | text | `thanks, bye, goodbye, done, stop, quit, end` | Disengagement triggers | ### 3.2 Versioning -Follow `release-workflow` skill: `v0.1.0`, `v0.2.0`, etc. Bump PATCH for -bugs, MINOR for features. MAJOR after 1.0 for breaking config schema changes. +Follow `release-workflow` skill: `v0.1.x` for small iterations. Bump PATCH for +bug fixes, MINOR for features. No MAJOR bump until stable release. --- @@ -236,171 +187,149 @@ bugs, MINOR for features. MAJOR after 1.0 for breaking config schema changes. | Hook | Purpose | |---|---| -| `OnActivate()` | Register slash command, create bot account via `API.CreateBot()`, initialise `EngagementEngine` | -| `OnConfigurationChange()` | Re-read System Console settings, validate OllamaURL, rebuild clients, rebuild stop-word trie | -| `MessageHasBeenPosted()` | **Primary entry point** — detect @-mentions and thread continuations, delegate to `EngagementEngine` | -| `ExecuteCommand()` | **Alternative entry point** — intercept `/ai` and subcommands | -| `OnDeactivate()` | Clean up goroutines, deactivate bot account, close idle HTTP connections | +| `OnActivate()` | Register `/ai` command, create bot account via `ensureBot()`, initialise `OllamaClient`, `ConversationStore`, `RateLimiter`, `EngagementEngine` | +| `OnConfigurationChange()` | Re-read System Console settings, validate OllamaURL, sync debug logging flag to OllamaClient | +| `MessageHasBeenPosted()` | **Primary entry point** — detect @-mentions and active sessions, delegate to EngagementEngine | +| `ExecuteCommand()` | **Alternative entry point** — intercept `/ai` and subcommands (`/ai persona`, `/ai new`, `/ai help`) | +| `Implemented()` | Declares handled hooks: `OnActivate`, `OnDeactivate`, `OnConfigurationChange`, `ExecuteCommand`, `MessageHasBeenPosted` | ### 4.2 Interaction Model -The plugin has two entry points, but **@-mention is the primary UX**. - #### 4.2.1 @-mention (primary) -User types `@mattermore ` in any channel. The plugin: +User types `@matty ` in any channel. The plugin: -1. Detects the @-mention via `MessageHasBeenPosted` → `model.Post.Mentions` +1. Detects the @-mention via `MessageHasBeenPosted` → string match on `@matty` 2. Strips the bot username from the prompt -3. Wakes the `EngagementEngine` for this user+thread -4. Creates a **thread root post** with the streamed response -5. Monitors thread replies for continuation — as long as the user keeps - replying in the thread, the bot auto-responds (no @-mention needed for - follow-ups in the same thread) +3. Checks stop words, rate limit, authorization +4. Looks up persona override from KV store (set via `/ai persona `) +5. Builds conversation context from `ConversationStore` (up to 10 turns) +6. Trims context based on Brevity setting (e.g. brevity 1 = only current message) +7. Prepends persona system prompt with general instructions + brevity +8. Calculates token cap (auto from Brevity or manual MaxTokens override) +9. Wakes the `EngagementEngine` for this user+channel +10. Sends to Ollama via `ChatCompletionStream` +11. Accumulates all tokens, creates a single channel post with the full response **When the bot does NOT respond:** -- Messages without `@mattermore` while the EngagementEngine is Sleeping -- Messages in threads the bot didn't start +- Messages without `@matty` while the EngagementEngine is Sleeping +- Messages during the Disengaging → Sleeping transition - Messages from other bot accounts (infinite-loop guard) -#### 4.2.2 Slash command (alternative) +#### 4.2.2 Engagement Session (continuation) -`/ai ` works as a fallback for users who prefer it or when -@-mention is inconvenient. Behaviour is identical — creates a thread, -activates the EngagementEngine, continues on thread replies. +Once a user is in an Active session (via @-mention or `/ai`), their subsequent +non-@-mention messages in the same channel are treated as continuations: + +``` +User enters Active → subsequent messages auto-continue → stop word → Sleep +``` + +The session expires after 5 minutes of inactivity. + +#### 4.2.3 Slash command (alternative) + +`/ai ` works as a fallback. Behaviour is identical. **Full syntax:** ``` -/ai - — Send a prompt using the default model (current session context). -/ai model - — Send a prompt using a specific model. -/ai new - — Reset conversation context for the current user+channel. -/ai model list - — List available models from the Ollama server. -/ai help - — Show usage help. +/ai — Chat with default model +/ai persona — Show current active persona +/ai persona list — List all available personas with descriptions +/ai persona — Switch persona (persisted) +/ai new — Reset conversation context + end session +/ai help — Show help ``` ### 4.3 OllamaClient -Interface (in `server/ollama/client.go`): +Located in `server/ollama.go` (package main, flat file). -```go -type Client interface { - ChatCompletion(ctx context.Context, req *ChatRequest) (<-chan ChatStreamEvent, error) - ListModels(ctx context.Context) ([]Model, error) - Ping(ctx context.Context) error -} +Key methods: -type ChatRequest struct { - Model string `json:"model"` - Messages []ChatMessage `json:"messages"` - Stream bool `json:"stream"` - Options map[string]any `json:"options,omitempty"` -} +| Method | Purpose | +|---|---| +| `ChatCompletion(req)` | Non-streaming call (not used by main flow) | +| `ChatCompletionStream(req)` | **Primary** — returns `< -chan StreamEvent`, reads ndjson | +| `ListModels()` | Returns available model names | +| `Ping()` | Health check | -type ChatStreamEvent struct { - Token string // delta content - Done bool - Error error -} -``` - -`TODO(design):` Should we use Ollama's `/api/chat` (chat format) or -`/api/generate` (raw prompt)? `/api/chat` is preferred — it maps cleanly to -the conversational UX and supports the messages array natively. +The client accepts a `logFunc` callback for debug logging (hooked to +`p.API.LogInfo` when `DebugLogging` is enabled). ### 4.4 ConversationStore -Store session context in Mattermost's KV store (plugin's `API.KVSet` / -`KVGet`). +Stored in KV store per `ctx_{userID}_{channelID}`. Contains the last 10 +messages (5 user + 5 assistant). Context is trimmed further based on +Brevity setting before sending to Ollama. -| Key pattern | Value | TTL | -|---|---|---| -| `ctx_{userID}_{channelID}` | `[]Message` (last N turns) | 1 hour | -| `ctx_{userID}_{channelID}_model` | Model name override | 1 hour | - -`TODO(design):` -- How many turns to keep? **Proposed:** last 10 messages (5 user + 5 assistant). -- TTL of 1 hour — resets on activity. Configurable? -- Should context be channel-scoped or user-global? Channel-scoped means a - user gets different context in different channels. +| Brevity | Messages kept | +|---|---| +| 1 | 1 (current only) | +| 2 | 2 (last exchange) | +| 3 | 4 (last 2 exchanges) | +| 5 | 6 | +| 10 | 10 | ### 4.5 RateLimiter -Token-bucket per user. Configurable requests/minute from System Console. -Resets on config change. Stored in memory, not KV (ephemeral state). +Token-bucket per user. Configurable from System Console. +Default: 0 (unlimited). Resets on config change. Stored in memory. -### 4.6 Engagement Lifecycle +### 4.6 EngagementEngine -The **EngagementEngine** is the core mechanism that prevents the bot from -replying to everything in the channel. It manages whether the bot should -respond to a given message. - -#### States +Tracks active conversations per user+channel. Two states: | State | Behaviour | Entered when | |---|---|---| -| **Sleeping** | Ignores all messages | Agent started, disengage triggered, timeout expired | -| **Active** | Responds to @-mentions and thread replies in the active session | User @-mentions bot or sends `/ai` | -| **Disengaging** | Responds to current message with a closing note, then sleeps | Stop word detected or user sends stop command | +| **Sleeping** | Ignores all messages | Plugin start, stop word, `/ai new`, 5 min timeout | +| **Active** | Responds to @-mentions and follow-ups | @-mention or `/ai` | + +The timeout is 5 minutes of inactivity. On expiry, the session is silently +removed — next @-mention starts fresh. + +### 4.7 Persona System + +Located in `server/persona.go`. A library of 19 personas mapped by stable +short IDs: ``` - ┌─────────────────┐ - │ Sleeping │ - └────────┬────────┘ - │ @-mention or /ai - ▼ - ┌─────────────────┐ - ┌──────│ Active │◄──── thread reply continues - │ └────────┬────────┘ - │ │ stop word / stop command / timeout - │ ▼ - │ ┌─────────────────┐ - │ │ Disengaging │── responds once, then... - │ └────────┬────────┘ - │ │ - └───────────────┘ +assistant a helpful assistant. Be concise, 2-3 sentences max... +canadian an overly polite Canadian who apologises excessively... +carlin George Carlin. Acerbic, observational, profane comedy... +dnd a Dungeons & Dragons dungeon master... +infomercial a 1980s TV infomercial host. Over-enthusiastic, loud... +jester a medieval court jester who mocks everyone... +mime a French mime. Respond with dramatic pauses... +motivational a motivational speaker who turns everything into a life lesson... +noir a noir detective who answers every question with another... +philosopher a philosopher who overcomplicates everything... +pirate a pirate captain. Use pirate slang... +pirro a drunk Jeanine Pirro. Slur your words... +ramsay Gordon Ramsay but for code reviews. Aggressive chef... +scammer a tech support scammer from 2008. Alarmist, pushy... +shakespeare William Shakespeare himself. Respond in iambic pentameter... +surfer a surfer dude explaining complex topics badly... +sysadmin a grumpy sysadmin who thinks every question is stupid... +tiredparent a tired parent who's given up on life... +trump Donald Trump. Boastful, hyperbolic, third-person... ``` -#### Disengagement Triggers +Priority: **KV override** (`/ai persona `) → **CustomPersona** (System Console text field) → **Persona** dropdown. -| Trigger | Example | Behaviour | +`resolvePersona(id)` looks up the short ID in the library. Returns empty +for unknown IDs (so `/ai persona shakespear` is properly rejected). + +### 4.8 Debug Logging + +When `DebugLogging` is enabled in System Console, the plugin logs: + +| Log message | Source | What it contains | |---|---|---| -| Stop word | "thanks", "thank you", "bye", "goodbye", "that's all", "that's it" | Bot responds with a brief closing message, then sleeps | -| Stop command | `@mattermore stop` | Bot sleeps immediately, no closing message | -| Thread timeout | No reply in thread for 15 minutes | Bot sleeps silently — next @-mention starts a fresh session | -| Model EOS signal | Model outputs `[EOS]` naturally | Same as stop word — clean disengagement from the AI's side | - -#### Stop word matching - -Stop words are configurable via System Console (`StopWords`). The plugin -builds a case-insensitive trie at config load. Matching is done on the -**user's raw prompt** (after stripping the @-mention), before sending to -Ollama — this avoids the latency and cost of a round-trip just to detect -a goodbye. - -The system prompt also instructs the model to append `[EOS]` when it -believes the conversation is naturally complete. The EngagementEngine -watches the response stream for this token as an additional trigger. - -#### Thread timeout - -15 minutes of inactivity in the thread → bot sleeps. The `ConversationStore` -TTL naturally handles cleanup. On wake, a new thread is created rather than -resuming the old one. - -`TODO(design):` Should the timeout be configurable via System Console? -**Proposed:** yes — `EngagementTimeoutMinutes` setting, default 15. - -#### Infinite-loop guard - -The plugin never processes messages from: -- Its own bot account (checked via `post.UserId == botUserID`) -- Any other bot account (`post.UserId` cross-referenced against `model.Bot`) +| `"Config loaded"` | `configuration.go` | All settings on save | +| `"Chat request"` | `hooks.go` | 13 fields: model, url, persona, brevity, tokens, repeat, freq, rate limit, allowed users, stop words, full system prompt | +| `"Ollama API call"` | `ollama.go` | Full request body sent to Ollama (model, messages, options) | --- @@ -408,157 +337,116 @@ The plugin never processes messages from: ### 5.1 API Mapping -| Plugin Action | Ollama Endpoint | Method | -|---|---|---| -| Chat completion (streaming) | `/api/chat` | POST | -| List models | `/api/tags` | GET | -| Health check | `/api/tags` (or HEAD `/`) | GET | -| Pull model | `/api/pull` | POST | - -`TODO(design):` Should the plugin auto-pull a model if it's not present? -Risk: user mistypes name → long pull. **Proposed:** return error with list of -available models instead. +| Plugin Action | Ollama Endpoint | Method | Stream | +|---|---|---|---| +| Chat completion | `/api/chat` | POST | Yes | +| List models | `/api/tags` | GET | No | +| Health check | `/api/tags` | GET | No | ### 5.2 Streaming Strategy 1. Send POST to `/api/chat` with `"stream": true`. -2. Read ndjson response body line by line (each line is `{"message":{"role":"assistant","content":"..."},"done":false}`). -3. On each `done:false` event, update the thread root post via - `API.UpdatePost()` with accumulated content. -4. On `done:true`, finalise the post with full content, usage stats - (token count, duration). +2. Read ndjson response body line by line. +3. Accumulate all tokens in a buffer (no live post updates — avoids "Edited" spam). +4. On `done:true`, create a single channel post with the full content. -`TODO(design):` Post-update on every token may be too chatty. **Proposed:** -batch updates every ~200 ms or every 3 tokens, whichever comes first. +### 5.3 Token Auto-Calculation -### 5.3 Context Window Management +Brevity auto-calculates the token cap. Formula: `100 + brevity × 75`. -- Token counting: use Ollama's `num_ctx` parameter or a Go tokenizer - (tiktoken-go). -- If input exceeds configured limit (default: 4096 tokens), trim oldest - messages until within limit, or reject if a single message overflows. -- Display a warning: "Context window trimmed (oldest messages removed)." +| Brevity | Auto tokens | Override with | +|---|---|---| +| 1 | 175 | MaxTokens > 175 | +| 2 | 250 | MaxTokens > 250 | +| 3 | 325 | MaxTokens > 325 | +| 5 | 475 | MaxTokens > 475 | +| 10 | 850 | MaxTokens > 850 | +| Unlimited (0) | Falls back to MaxTokens (0 = Ollama default) | MaxTokens > 0 | -`TODO(design):` Should the user be able to set `num_ctx` per-request? -`@mattermore --context 8192 ...` +### 5.4 Penalties -### 5.4 Model Selection Precedence +RepeatPenalty and FrequencyPenalty use whole numbers divided by 10: -1. Per-request: `@mattermore model gemma3:12b write a poem` -2. Session override: `/ai model gemma3:12b` (stored in KV) -3. Default from System Console: `DefaultModel` +| UI value | Sent to Ollama | +|---|---| +| 10 | 1.0 (none) | +| 11 | 1.1 (mild) | +| 12 | 1.2 (strong) | + +| UI value | Sent to Ollama | +|---|---| +| 0 | 0.0 (none) | +| 1 | 0.1 (mild) | +| 5 | 0.5 (strong) | --- ## 6. Configuration -### 6.1 System Console Settings +All configuration is done via **System Console → Plugins → Matty**. +The 12 settings are described in §3.1. -Defined in `plugin.json` `settings_schema` (see §3.1). The System Console -provides the UI; the plugin reads via `OnConfigurationChange()`. - -### 6.2 Dynamic Config Reload - -`OnConfigurationChange()` is called on every save. The plugin must: -1. Re-read the config struct from `API.GetConfig()` / `API.GetPluginConfig()`. -2. Validate `OllamaURL` (parse as URL, reject non-HTTP(S) schemes). -3. If URL changed, create a new `http.Client` (with configurable timeout). -4. If `RateLimitPerMinute` changed, rebuild the token buckets. -5. If `StopWords` changed, rebuild the stop-word trie. -6. Log the change at debug level. +`OnConfigurationChange()` reloads all settings when saved. Plugins do not +have a `SavePluginConfiguration` API in Mattermost v11.x, so `/ai persona` +uses the KV store to persist overrides instead. --- ## 7. Data Flow — Detailed -### 7.1 Full Lifecycle +### 7.1 MessageHasBeenPosted (full lifecycle) ``` Step User / System Component ──── ────────────────────────────── ────────────────── - 1 User types "@mattermore explain TCP" Mattermost webapp - 2 MessageHasBeenPosted fires Mattermore plugin - 3 EngagementEngine.Wake() EngagementEngine - 3a Check not a bot post (loop guard) - 3b Check @-mention or active thread - 3c Transition to Active - 4 Strip "@mattermore " → "explain TCP" - 5 Check stop-word trie (no match) - 6 RateLimiter.Allow(userId) RateLimiter - 7 ConversationStore.GetSession(...) ConversationStore - 8 Build []ChatMessage (system + history + new prompt) - 9 OllamaClient.ChatCompletion(...) OllamaClient - 10 Ollama streams ndjson tokens Ollama (remote) - 11 Plugin accumulates tokens, updates Mattermore plugin - thread post every ~200ms - 12 On done:true → finalise post Mattermore plugin - 13 Check response for [EOS] signal EngagementEngine - 14 ConversationStore.Append(...) ConversationStore -``` - -### 7.2 Disengagement Flow - -``` -Step User / System Component -──── ────────────────────────────── ────────────────── - 1 User replies "thanks that's all" + 1 User types "@matty explain TCP" 2 MessageHasBeenPosted fires - 3 EngagementEngine.IsActive → true - 4 Strip @-mention if present - 5 Stop-word trie matches "that's all" StopWordTrie - 6 EngagementEngine.Transition(Disengaging) - 7 OllamaClient.ChatCompletion(lastMsg) (brief closing response) - 8 Post closing message in thread - 9 EngagementEngine.Transition(Sleeping) + 3 Check: not bot's own post Loop guard + 4 Check: not from OAuth bot Loop guard + 5 Check: has @matty? yes Mention detection + 6 Check: EngagementEngine.IsActive? EngagementEngine + 7 Strip @matty → "explain TCP" + 8 Check stop words StopWordSet + 9 Check rate limit RateLimiter + 10 Check authorization AllowedUserIDs + 11 EngagementEngine.Wake() EngagementEngine + 12 GetPersonaOverride → resolvePersona KV Store / personaLibrary + 13 BuildMessages → conversation history ConversationStore + 14 Trim context by Brevity hooks.go + 15 Build system prompt + brevity hooks.go + 16 Calculate token cap hooks.go + 17 Build Ollama options (penalties) hooks.go + 18 Log Chat request (if debug) hooks.go + 19 ChatCompletionStream() OllamaClient + 20 Accumulate tokens hooks.go + 21 CreatePost with full response postReply() ``` -### 7.3 Thread Participation - -Thread replies continue the conversation without requiring a new @-mention. -The EngagementEngine tracks which threads it owns (via `ownerThread_{threadID}` -in KV). For posts in owned threads: -- If the EngagementEngine is Active for this user+thread → respond -- If Sleeping → respond once to wake, then continue -- If Disengaging → respond with closing note, then sleep - --- ## 8. Security Model ### 8.1 Input Sanitization -- Strip control characters / zero-width Unicode from prompts before sending - to Ollama. -- Limit prompt length to 4096 characters (configurable via System Console). -- `TODO(design):` Should we block prompts that look like prompt injections? - ("Ignore previous instructions...") Hard to do reliably — document risk - instead. +- Stop words checked before sending to Ollama (saves round-trip) +- Rate limiting per user (configurable, default: unlimited) -### 8.2 Output Safety +### 8.2 Network Security -- Cap response tokens in the Ollama request (`options.num_predict`). -- `TODO(design):` Add a "Report this response" feedback mechanism? Defer. +- `OllamaURL` validated as http/https at config load +- TLS enabled by default -### 8.3 Network Security +### 8.3 Authorization -- `OllamaURL` must start with `http://` or `https://` — validate at config - load. -- Reject internal IP ranges unless explicitly enabled by a - `AllowPrivateNetworks` toggle (default: off for production). -- TLS verify enabled by default; optional `SkipTLSVerify` toggle (logged as - a warning). +- `AllowedUserIDs` restricts by Mattermost user ID +- Empty = all authenticated users +- Bot account created by plugin, managed automatically -### 8.4 Authorization +### 8.4 Infinite-Loop Guard -- If `AllowedUserIDs` is non-empty, reject requests from other users with a - "not authorized" ephemeral message. -- All plugin API calls are already scoped to authenticated Mattermost users. - -### 8.5 Rate Limiting - -- Per-user token bucket, replenish rate set via System Console. -- Default: 10 requests/minute. 0 = unlimited (logged as a warning). -- Burst: 1 (enforce smooth spacing). +The plugin never processes messages from: +- Its own bot account (`post.UserId == botUserID`) +- Other OAuth bots (`post.IsFromOAuthBot()`) --- @@ -566,49 +454,46 @@ in KV). For posts in owned threads: ### 9.1 Logging +When Debug Logging is enabled: + | Level | When | |---|---| +| `INFO` | Chat request (all 13 settings), Config loaded, Ollama API call body | | `ERROR` | Ollama unreachable, config validation failure, KV store error | -| `WARN` | Rate limit hit, context window trimmed, TLS skipped | -| `INFO` | Plugin activated/deactivated, config changed, first request | -| `DEBUG` | Every request/response round-trip (with userID, model, token count, latency, engagement state) | +| `WARN` | Rate limit hit (with debug on) | -### 9.2 Metrics (future) +### 9.2 Error Reporting -`TODO(design):` Expose Prometheus-style counters via a `/metrics` endpoint -if the Mattermost server has plugin metrics support: -- `mattermore_requests_total{user,model,status}` -- `mattermore_latency_seconds` -- `mattermore_tokens_total{direction="input|output"}` -- `mattermore_rate_limit_hits_total` - -### 9.3 Error Reporting - -Ephemeral posts with user-facing messages (not raw Go errors). Internal -errors logged at `ERROR` with stack trace. +- Ollama unreachable → "Matty is asleep. @matty when you need me." +- Rate limit → "Rate limit exceeded..." +- Unknown persona → "Unknown persona. Available: ..." +- Stream error → appended as "(Matty is asleep...)" in the response --- -## 10. Open Design Decisions +## 10. Resolved & Open Design Decisions -These are the unresolved questions that need answers before v0.1.0 ships. +### Resolved -| # | Question | Proposed | Alternatives | Decided? | -|---|---|---|---|---| -| D1 | Reply style | **Thread posts** (not ephemeral) — community sees the interaction | Ephemeral, channel-wide | ✅ → DECISIONS.md | -| D2 | Context scope | Per-channel per-user — each channel gets its own conversation | Per-user global, per-channel only, no context | ❌ | -| D3 | Context turns | Last 5 exchanges (10 messages) | Last N tokens, everything in TTL, sliding window | ❌ | -| D4 | Webapp UI | **None** — @-mention + thread replies cover all interaction | Channel header button, RHS panel | ✅ → DECISIONS.md | -| D5 | Model governance | All models available | Admin-restricted model list, least-privilege Ollama API key | ❌ | -| D6 | Thread replies | **Core feature** — the EngagementEngine auto-follows thread replies | Slash-command-only, ephemeral | ✅ → DECISIONS.md | -| D7 | `num_ctx` per request | Via `--context N` flag | System Console default only | ❌ | -| D8 | Auto-pull models | No (error + list available) | Yes (convenient but slow) | ❌ | -| D9 | Prompt injection guard | Defer (document risk) | Regex blocklist, LLM-as-judge pre-filter | ❌ | -| D10 | Metrics endpoint | Defer | `/metrics` via plugin HTTP handler | ❌ | -| D11 | Engagement scope | Per-user+channel | Per-user global, per-channel only | ❌ | -| D12 | Thread timeout | 15 minutes, configurable | Fixed 30min, no timeout | ❌ | +| # | Question | Decision | +|---|---|---| +| D1 | Reply style | **Direct channel posts** (not ephemeral, not threads) | +| D4 | Webapp UI | **None** — @-mention + `/ai` + `/ai persona` covers everything | +| D6 | Thread replies | **EngagementEngine** — active sessions track follow-ups without threads | +| D11 | Engagement scope | **Per-user+channel** — separate sessions per channel | +| D12 | Thread timeout | **5 minutes** (not configurable yet) | -Each decision should be recorded in `DECISIONS.md` once resolved. +### Still Open + +| # | Question | Proposed | +|---|---|---| +| D2 | Context scope | Per-channel per-user (implemented) | +| D3 | Context turns | Currently 10 messages; Brevity trims further | +| D5 | Model governance | All models available via System Console dropdown | +| D7 | `num_ctx` per request | Not implemented; Brevity controls token cap | +| D8 | Auto-pull models | No (returns error with list) | +| D9 | Prompt injection guard | Deferred — document risk | +| D10 | Metrics endpoint | Deferred | --- @@ -620,42 +505,67 @@ Each decision should be recorded in `DECISIONS.md` once resolved. make dist ``` -Produces `dist/com.forkless.mattermore-0.1.0.tar.gz` containing the server -binaries and `plugin.json`. +Produces `dist/com.forkless.mattermore-{version}.tar.gz` containing the plugin +manifest and platform binaries. ### 11.2 Upload -Via Mattermost System Console → Plugins → Upload Plugin, or via REST API: - -```bash -curl -X POST $MM_URL/api/v4/plugins \ - -H "Authorization: Bearer $MM_TOKEN" \ - -F "plugin=@dist/com.forkless.mattermore-0.1.0.tar.gz" -``` +Via Mattermost System Console → Plugins → Upload Plugin, or via API. ### 11.3 Upgrade -1. Upload new `.tar.gz` — Mattermost replaces the plugin binaries. -2. Plugin's `OnConfigurationChange()` fires (config preserved). -3. Bot account is re-fetched (not re-created) — idempotent. -4. If the KV schema changed, a migration in `OnActivate()` handles it. +Upload new `.tar.gz` — Mattermost replaces the plugin binary. +Settings are preserved via KV store. Bot account is re-fetched (not re-created). -### 11.4 Rollback +### 11.4 Local Testing -Re-upload the previous version's `.tar.gz`. No data migration needed if the -KV key schema hasn't changed. Bot account survives rollback. If it has, -restore from backup or `DECISIONS.md` records the migration path. +```bash +docker compose -f dev/docker-compose.yml up -d +``` + +Spins up Mattermost 11.6.1 + PostgreSQL 16 at http://localhost:8065. +Auto-creates admin user (admin/password). +Test rig is disposable: `docker compose down -v` wipes everything. --- -## 12. Future Considerations (v0.2+) +## 12. File Structure + +``` +mattermore/ +├── plugin.json — Plugin manifest +├── Makefile — Build, dist, clean +├── .golangci.yml — Linter config +├── DESIGN.md — This document +├── PROJECT.md +├── CHANGES.md +├── DECISIONS.md +├── README.md +├── dev/ +│ ├── docker-compose.yml — Local Mattermost + PostgreSQL +│ └── init.sh — Admin auto-creation script +├── server/ +│ ├── main.go — plugin.ClientMain +│ ├── plugin.go — Plugin struct +│ ├── hooks.go — OnActivate, MessageHasBeenPosted, streaming +│ ├── command.go — /ai and subcommands, persona override +│ ├── configuration.go — Config struct, validation, OnConfigurationChange +│ ├── ollama.go — OllamaClient (streaming + blocking) +│ ├── store.go — ConversationStore (KV-backed) +│ ├── rate.go — RateLimiter (token bucket) +│ ├── engagement.go — EngagementEngine (sleep/active) +│ └── persona.go — PersonaLibrary with 19 personas +└── dist/ + └── com.forkless.mattermore-*.tar.gz +``` + +--- + +## 13. Future Considerations (v0.2+) - **Multi-modal inputs** — Ollama supports images via `/api/chat` with - `images` field. Could allow image attachment in thread. -- **Multi-model routing** — Different models for different channel categories - (e.g. code-review channel uses codellama). -- **Tool calling** — Ollama supports tool definitions. Could let the - plugin fetch data from external APIs (weather, Jira, etc.). + `images` field. +- **Tool calling** — Ollama supports tool definitions for external APIs. +- **Conversation export** — Allow users to export AI threads as markdown. - **Streaming into message attachments** — Render structured data (tables, JSON, code blocks) with better formatting. -- **Conversation export** — Allow users to export an AI thread as markdown.