Rewrite DESIGN.md to reflect current v0.1.16 state

This commit is contained in:
2026-06-16 21:57:57 +02:00
parent 7f3caee8ec
commit 72d4821a26
+330 -420
View File
@@ -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 > **Last updated:** 2026-06-16
> **Assumed direction:** AI chat agent plugin connecting to a remote Ollama instance. > **Bot username:** @matty
> **Open questions are marked** `TODO(design):` throughout. This is a living template — change > **Default model:** dolphin-llama3:latest (configurable in System Console)
> assumptions as the product direction firms up.
--- ---
@@ -12,12 +11,16 @@
### 1.1 What It Is ### 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 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 bot responds **only when addressed** and disengages naturally — it never
reads every message in the room. A slash command (`/ai`) remains available reads every message in the room. Once addressed, the user enters an
as an alternative entry point. **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) ### 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 | | 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 | | Multi-backend abstraction (OpenAI, Anthropic, etc.) | If needed later, abstract behind an `LLMProvider` interface — not now |
| Training / fine-tuning | Ollama handles that | | 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 ### 1.3 Target Audience
@@ -46,7 +51,7 @@ as an alternative entry point.
│ Mattermost Server (Community Edition) │ │ Mattermost Server (Community Edition) │
│ │ │ │
│ ┌───────────────────────────────────────────────────────┐ │ │ ┌───────────────────────────────────────────────────────┐ │
│ │ Mattermore Plugin │ │ │ │ Matty Plugin │ │
│ │ │ │ │ │ │ │
│ │ ┌──────────────────┐ ┌────────────────────────┐ │ │ │ │ ┌──────────────────┐ ┌────────────────────────┐ │ │
│ │ │ MessageHasBeen │──▶│ Plugin (Go) │ │ │ │ │ │ MessageHasBeen │──▶│ Plugin (Go) │ │ │
@@ -58,85 +63,93 @@ as an alternative entry point.
│ │ └──────────────────┘ │ │ ConversationStore│ │ │ │ │ │ └──────────────────┘ │ │ ConversationStore│ │ │ │
│ │ ┌──────────────────┐ │ └──────────────────┘ │ │ │ │ │ ┌──────────────────┐ │ └──────────────────┘ │ │ │
│ │ │ EngagementEngine │ │ ┌──────────────────┐ │ │ │ │ │ │ EngagementEngine │ │ ┌──────────────────┐ │ │ │
│ │ │ wake / sleep │──▶│ │ RateLimiter │ │ │ │ │ │ │ sleep / active │──▶│ │ RateLimiter │ │ │ │
│ │ └──────────────────┘ │ └──────────────────┘ │ │ │ │ │ └──────────────────┘ │ └──────────────────┘ │ │ │
│ │ ┌──────────────────┐ │ ┌──────────────────┐ │ │ │ │ │ ┌──────────────────┐ │ ┌──────────────────┐ │ │ │
│ │ │ Bot Account │ │ │ Config (KV) │ │ │ │ │ │ │ PersonaLibrary │ │ │ Config (KV) │ │ │ │
│ │ │ (auto-created) │ │ └──────────────────┘ │ │ │ │ │ │ resolvePersona() │ │ └──────────────────┘ │ │ │
│ │ └──────────────────┘ │ │ │ │
│ │ ┌──────────────────┐ │ │ │ │
│ │ │ Bot Account │ │ │ │ │
│ │ │ (auto-created) │ │ │ │ │
│ │ └──────────────────┘ │ │ │ │ │ │ └──────────────────┘ │ │ │ │
│ │ ┌──────────────────┐ │ │ │ │ │ │ ┌──────────────────┐ │ │ │ │
│ │ │ Webapp (none) │ │ │ │ │ │ │ │ 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 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?" 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
Mattermore MessageHasBeenPosted fires (same thread as bot post) 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
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)
``` ```
### 2.3 Error Flow ### 2.4 Error Flow
| Failure point | Behaviour | | Failure point | Behaviour |
|---|---| |---|---|
| Ollama unreachable | Ephemeral post: "Ollama at <url> unreachable. Check configuration." | | Ollama unreachable | Post: "Matty is asleep. @matty when you need me." |
| Rate limit hit | Ephemeral post: "Rate limit exceeded. Try again in N seconds." | | Rate limit hit | Post: "Rate limit exceeded. Please wait before sending another request." |
| Invalid model name | Ephemeral post: "Model 'xyz' not found on Ollama server." | | Invalid persona | Ephemeral: "Unknown persona. Available: ..." |
| Timeout (>30 s) | Abort stream, post partial response + "Response truncated (timeout)." | | Timeout (>30 s) | Post truncated with "(Matty is asleep...)" |
| Input too long | Reject with context window limit message; suggest `@mattermore new` to reset. |
--- ---
@@ -144,89 +157,27 @@ ConversationStore.Append(userId, channelId, userMsg, assistantMsg)
### 3.1 `plugin.json` ### 3.1 `plugin.json`
```json Current settings (v0.1.16):
{
"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"
}
]
}
}
```
`TODO(design):` Should `AllowedUserIDs` be a team/channel whitelist instead? | Key | Type | Default | Purpose |
For now, keep user-level granularity — easy to widen later. |---|---|---|---|
| `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 ### 3.2 Versioning
Follow `release-workflow` skill: `v0.1.0`, `v0.2.0`, etc. Bump PATCH for Follow `release-workflow` skill: `v0.1.x` for small iterations. Bump PATCH for
bugs, MINOR for features. MAJOR after 1.0 for breaking config schema changes. 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 | | Hook | Purpose |
|---|---| |---|---|
| `OnActivate()` | Register slash command, create bot account via `API.CreateBot()`, initialise `EngagementEngine` | | `OnActivate()` | Register `/ai` command, create bot account via `ensureBot()`, initialise `OllamaClient`, `ConversationStore`, `RateLimiter`, `EngagementEngine` |
| `OnConfigurationChange()` | Re-read System Console settings, validate OllamaURL, rebuild clients, rebuild stop-word trie | | `OnConfigurationChange()` | Re-read System Console settings, validate OllamaURL, sync debug logging flag to OllamaClient |
| `MessageHasBeenPosted()` | **Primary entry point** — detect @-mentions and thread continuations, delegate to `EngagementEngine` | | `MessageHasBeenPosted()` | **Primary entry point** — detect @-mentions and active sessions, delegate to EngagementEngine |
| `ExecuteCommand()` | **Alternative entry point** — intercept `/ai` and subcommands | | `ExecuteCommand()` | **Alternative entry point** — intercept `/ai` and subcommands (`/ai persona`, `/ai new`, `/ai help`) |
| `OnDeactivate()` | Clean up goroutines, deactivate bot account, close idle HTTP connections | | `Implemented()` | Declares handled hooks: `OnActivate`, `OnDeactivate`, `OnConfigurationChange`, `ExecuteCommand`, `MessageHasBeenPosted` |
### 4.2 Interaction Model ### 4.2 Interaction Model
The plugin has two entry points, but **@-mention is the primary UX**.
#### 4.2.1 @-mention (primary) #### 4.2.1 @-mention (primary)
User types `@mattermore <prompt>` in any channel. The plugin: User types `@matty <prompt>` 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 2. Strips the bot username from the prompt
3. Wakes the `EngagementEngine` for this user+thread 3. Checks stop words, rate limit, authorization
4. Creates a **thread root post** with the streamed response 4. Looks up persona override from KV store (set via `/ai persona <name>`)
5. Monitors thread replies for continuation — as long as the user keeps 5. Builds conversation context from `ConversationStore` (up to 10 turns)
replying in the thread, the bot auto-responds (no @-mention needed for 6. Trims context based on Brevity setting (e.g. brevity 1 = only current message)
follow-ups in the same thread) 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:** **When the bot does NOT respond:**
- Messages without `@mattermore` while the EngagementEngine is Sleeping - Messages without `@matty` while the EngagementEngine is Sleeping
- Messages in threads the bot didn't start - Messages during the Disengaging → Sleeping transition
- Messages from other bot accounts (infinite-loop guard) - Messages from other bot accounts (infinite-loop guard)
#### 4.2.2 Slash command (alternative) #### 4.2.2 Engagement Session (continuation)
`/ai <prompt>` works as a fallback for users who prefer it or when Once a user is in an Active session (via @-mention or `/ai`), their subsequent
@-mention is inconvenient. Behaviour is identical — creates a thread, non-@-mention messages in the same channel are treated as continuations:
activates the EngagementEngine, continues on thread replies.
```
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 <prompt>` works as a fallback. Behaviour is identical.
**Full syntax:** **Full syntax:**
``` ```
/ai <prompt> /ai <prompt> — Chat with default model
— Send a prompt using the default model (current session context). /ai persona — Show current active persona
/ai model <name> <prompt> /ai persona list — List all available personas with descriptions
— Send a prompt using a specific model. /ai persona <name> — Switch persona (persisted)
/ai new /ai new — Reset conversation context + end session
— Reset conversation context for the current user+channel. /ai help — Show help
/ai model list
— List available models from the Ollama server.
/ai help
— Show usage help.
``` ```
### 4.3 OllamaClient ### 4.3 OllamaClient
Interface (in `server/ollama/client.go`): Located in `server/ollama.go` (package main, flat file).
```go Key methods:
type Client interface {
ChatCompletion(ctx context.Context, req *ChatRequest) (<-chan ChatStreamEvent, error)
ListModels(ctx context.Context) ([]Model, error)
Ping(ctx context.Context) error
}
type ChatRequest struct { | Method | Purpose |
Model string `json:"model"` |---|---|
Messages []ChatMessage `json:"messages"` | `ChatCompletion(req)` | Non-streaming call (not used by main flow) |
Stream bool `json:"stream"` | `ChatCompletionStream(req)` | **Primary** — returns `< -chan StreamEvent`, reads ndjson |
Options map[string]any `json:"options,omitempty"` | `ListModels()` | Returns available model names |
} | `Ping()` | Health check |
type ChatStreamEvent struct { The client accepts a `logFunc` callback for debug logging (hooked to
Token string // delta content `p.API.LogInfo` when `DebugLogging` is enabled).
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.
### 4.4 ConversationStore ### 4.4 ConversationStore
Store session context in Mattermost's KV store (plugin's `API.KVSet` / Stored in KV store per `ctx_{userID}_{channelID}`. Contains the last 10
`KVGet`). messages (5 user + 5 assistant). Context is trimmed further based on
Brevity setting before sending to Ollama.
| Key pattern | Value | TTL | | Brevity | Messages kept |
|---|---|---| |---|---|
| `ctx_{userID}_{channelID}` | `[]Message` (last N turns) | 1 hour | | 1 | 1 (current only) |
| `ctx_{userID}_{channelID}_model` | Model name override | 1 hour | | 2 | 2 (last exchange) |
| 3 | 4 (last 2 exchanges) |
`TODO(design):` | 5 | 6 |
- How many turns to keep? **Proposed:** last 10 messages (5 user + 5 assistant). | 10 | 10 |
- 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.
### 4.5 RateLimiter ### 4.5 RateLimiter
Token-bucket per user. Configurable requests/minute from System Console. Token-bucket per user. Configurable from System Console.
Resets on config change. Stored in memory, not KV (ephemeral state). 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 Tracks active conversations per user+channel. Two states:
replying to everything in the channel. It manages whether the bot should
respond to a given message.
#### States
| State | Behaviour | Entered when | | State | Behaviour | Entered when |
|---|---|---| |---|---|---|
| **Sleeping** | Ignores all messages | Agent started, disengage triggered, timeout expired | | **Sleeping** | Ignores all messages | Plugin start, stop word, `/ai new`, 5 min timeout |
| **Active** | Responds to @-mentions and thread replies in the active session | User @-mentions bot or sends `/ai` | | **Active** | Responds to @-mentions and follow-ups | @-mention or `/ai` |
| **Disengaging** | Responds to current message with a closing note, then sleeps | Stop word detected or user sends stop command |
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:
``` ```
┌─────────────────┐ assistant a helpful assistant. Be concise, 2-3 sentences max...
│ Sleeping │ canadian an overly polite Canadian who apologises excessively...
└────────┬────────┘ carlin George Carlin. Acerbic, observational, profane comedy...
│ @-mention or /ai dnd a Dungeons & Dragons dungeon master...
infomercial a 1980s TV infomercial host. Over-enthusiastic, loud...
┌─────────────────┐ jester a medieval court jester who mocks everyone...
┌──────│ Active │◄──── thread reply continues mime a French mime. Respond with dramatic pauses...
│ └────────┬────────┘ motivational a motivational speaker who turns everything into a life lesson...
│ │ stop word / stop command / timeout noir a noir detective who answers every question with another...
│ ▼ philosopher a philosopher who overcomplicates everything...
│ ┌─────────────────┐ pirate a pirate captain. Use pirate slang...
│ │ Disengaging │── responds once, then... 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 <id>`) → **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 | | `"Config loaded"` | `configuration.go` | All settings on save |
| Stop command | `@mattermore stop` | Bot sleeps immediately, no closing message | | `"Chat request"` | `hooks.go` | 13 fields: model, url, persona, brevity, tokens, repeat, freq, rate limit, allowed users, stop words, full system prompt |
| Thread timeout | No reply in thread for 15 minutes | Bot sleeps silently — next @-mention starts a fresh session | | `"Ollama API call"` | `ollama.go` | Full request body sent to Ollama (model, messages, options) |
| 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`)
--- ---
@@ -408,157 +337,116 @@ The plugin never processes messages from:
### 5.1 API Mapping ### 5.1 API Mapping
| Plugin Action | Ollama Endpoint | Method | | Plugin Action | Ollama Endpoint | Method | Stream |
|---|---|---| |---|---|---|---|
| Chat completion (streaming) | `/api/chat` | POST | | Chat completion | `/api/chat` | POST | Yes |
| List models | `/api/tags` | GET | | List models | `/api/tags` | GET | No |
| Health check | `/api/tags` (or HEAD `/`) | GET | | Health check | `/api/tags` | GET | No |
| 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.
### 5.2 Streaming Strategy ### 5.2 Streaming Strategy
1. Send POST to `/api/chat` with `"stream": true`. 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}`). 2. Read ndjson response body line by line.
3. On each `done:false` event, update the thread root post via 3. Accumulate all tokens in a buffer (no live post updates — avoids "Edited" spam).
`API.UpdatePost()` with accumulated content. 4. On `done:true`, create a single channel post with the full content.
4. On `done:true`, finalise the post with full content, usage stats
(token count, duration).
`TODO(design):` Post-update on every token may be too chatty. **Proposed:** ### 5.3 Token Auto-Calculation
batch updates every ~200 ms or every 3 tokens, whichever comes first.
### 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 | Brevity | Auto tokens | Override with |
(tiktoken-go). |---|---|---|
- If input exceeds configured limit (default: 4096 tokens), trim oldest | 1 | 175 | MaxTokens > 175 |
messages until within limit, or reject if a single message overflows. | 2 | 250 | MaxTokens > 250 |
- Display a warning: "Context window trimmed (oldest messages removed)." | 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? ### 5.4 Penalties
`@mattermore --context 8192 ...`
### 5.4 Model Selection Precedence RepeatPenalty and FrequencyPenalty use whole numbers divided by 10:
1. Per-request: `@mattermore model gemma3:12b write a poem` | UI value | Sent to Ollama |
2. Session override: `/ai model gemma3:12b` (stored in KV) |---|---|
3. Default from System Console: `DefaultModel` | 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. 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 `OnConfigurationChange()` reloads all settings when saved. Plugins do not
provides the UI; the plugin reads via `OnConfigurationChange()`. have a `SavePluginConfiguration` API in Mattermost v11.x, so `/ai persona`
uses the KV store to persist overrides instead.
### 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.
--- ---
## 7. Data Flow — Detailed ## 7. Data Flow — Detailed
### 7.1 Full Lifecycle ### 7.1 MessageHasBeenPosted (full lifecycle)
``` ```
Step User / System Component Step User / System Component
──── ────────────────────────────── ────────────────── ──── ────────────────────────────── ──────────────────
1 User types "@mattermore explain TCP" Mattermost webapp 1 User types "@matty explain TCP"
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"
2 MessageHasBeenPosted fires 2 MessageHasBeenPosted fires
3 EngagementEngine.IsActive → true 3 Check: not bot's own post Loop guard
4 Strip @-mention if present 4 Check: not from OAuth bot Loop guard
5 Stop-word trie matches "that's all" StopWordTrie 5 Check: has @matty? yes Mention detection
6 EngagementEngine.Transition(Disengaging) 6 Check: EngagementEngine.IsActive? EngagementEngine
7 OllamaClient.ChatCompletion(lastMsg) (brief closing response) 7 Strip @matty → "explain TCP"
8 Post closing message in thread 8 Check stop words StopWordSet
9 EngagementEngine.Transition(Sleeping) 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. Security Model
### 8.1 Input Sanitization ### 8.1 Input Sanitization
- Strip control characters / zero-width Unicode from prompts before sending - Stop words checked before sending to Ollama (saves round-trip)
to Ollama. - Rate limiting per user (configurable, default: unlimited)
- 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.
### 8.2 Output Safety ### 8.2 Network Security
- Cap response tokens in the Ollama request (`options.num_predict`). - `OllamaURL` validated as http/https at config load
- `TODO(design):` Add a "Report this response" feedback mechanism? Defer. - TLS enabled by default
### 8.3 Network Security ### 8.3 Authorization
- `OllamaURL` must start with `http://` or `https://` — validate at config - `AllowedUserIDs` restricts by Mattermost user ID
load. - Empty = all authenticated users
- Reject internal IP ranges unless explicitly enabled by a - Bot account created by plugin, managed automatically
`AllowPrivateNetworks` toggle (default: off for production).
- TLS verify enabled by default; optional `SkipTLSVerify` toggle (logged as
a warning).
### 8.4 Authorization ### 8.4 Infinite-Loop Guard
- If `AllowedUserIDs` is non-empty, reject requests from other users with a The plugin never processes messages from:
"not authorized" ephemeral message. - Its own bot account (`post.UserId == botUserID`)
- All plugin API calls are already scoped to authenticated Mattermost users. - Other OAuth bots (`post.IsFromOAuthBot()`)
### 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).
--- ---
@@ -566,49 +454,46 @@ in KV). For posts in owned threads:
### 9.1 Logging ### 9.1 Logging
When Debug Logging is enabled:
| Level | When | | Level | When |
|---|---| |---|---|
| `INFO` | Chat request (all 13 settings), Config loaded, Ollama API call body |
| `ERROR` | Ollama unreachable, config validation failure, KV store error | | `ERROR` | Ollama unreachable, config validation failure, KV store error |
| `WARN` | Rate limit hit, context window trimmed, TLS skipped | | `WARN` | Rate limit hit (with debug on) |
| `INFO` | Plugin activated/deactivated, config changed, first request |
| `DEBUG` | Every request/response round-trip (with userID, model, token count, latency, engagement state) |
### 9.2 Metrics (future) ### 9.2 Error Reporting
`TODO(design):` Expose Prometheus-style counters via a `/metrics` endpoint - Ollama unreachable → "Matty is asleep. @matty when you need me."
if the Mattermost server has plugin metrics support: - Rate limit → "Rate limit exceeded..."
- `mattermore_requests_total{user,model,status}` - Unknown persona → "Unknown persona. Available: ..."
- `mattermore_latency_seconds` - Stream error → appended as "(Matty is asleep...)" in the response
- `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.
--- ---
## 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? | | # | Question | Decision |
|---|---|---|---|---| |---|---|---|
| D1 | Reply style | **Thread posts** (not ephemeral) — community sees the interaction | Ephemeral, channel-wide | ✅ → DECISIONS.md | | D1 | Reply style | **Direct channel posts** (not ephemeral, not threads) |
| D2 | Context scope | Per-channel per-user — each channel gets its own conversation | Per-user global, per-channel only, no context | ❌ | | D4 | Webapp UI | **None** — @-mention + `/ai` + `/ai persona` covers everything |
| D3 | Context turns | Last 5 exchanges (10 messages) | Last N tokens, everything in TTL, sliding window | ❌ | | D6 | Thread replies | **EngagementEngine** — active sessions track follow-ups without threads |
| D4 | Webapp UI | **None**@-mention + thread replies cover all interaction | Channel header button, RHS panel | ✅ → DECISIONS.md | | D11 | Engagement scope | **Per-user+channel**separate sessions per channel |
| D5 | Model governance | All models available | Admin-restricted model list, least-privilege Ollama API key | ❌ | | D12 | Thread timeout | **5 minutes** (not configurable yet) |
| 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 | ❌ |
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 make dist
``` ```
Produces `dist/com.forkless.mattermore-0.1.0.tar.gz` containing the server Produces `dist/com.forkless.mattermore-{version}.tar.gz` containing the plugin
binaries and `plugin.json`. manifest and platform binaries.
### 11.2 Upload ### 11.2 Upload
Via Mattermost System Console → Plugins → Upload Plugin, or via REST API: Via Mattermost System Console → Plugins → Upload Plugin, or via 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"
```
### 11.3 Upgrade ### 11.3 Upgrade
1. Upload new `.tar.gz` — Mattermost replaces the plugin binaries. Upload new `.tar.gz` — Mattermost replaces the plugin binary.
2. Plugin's `OnConfigurationChange()` fires (config preserved). Settings are preserved via KV store. Bot account is re-fetched (not re-created).
3. Bot account is re-fetched (not re-created) — idempotent.
4. If the KV schema changed, a migration in `OnActivate()` handles it.
### 11.4 Rollback ### 11.4 Local Testing
Re-upload the previous version's `.tar.gz`. No data migration needed if the ```bash
KV key schema hasn't changed. Bot account survives rollback. If it has, docker compose -f dev/docker-compose.yml up -d
restore from backup or `DECISIONS.md` records the migration path. ```
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 - **Multi-modal inputs** — Ollama supports images via `/api/chat` with
`images` field. Could allow image attachment in thread. `images` field.
- **Multi-model routing** — Different models for different channel categories - **Tool calling** — Ollama supports tool definitions for external APIs.
(e.g. code-review channel uses codellama). - **Conversation export** — Allow users to export AI threads as markdown.
- **Tool calling** — Ollama supports tool definitions. Could let the
plugin fetch data from external APIs (weather, Jira, etc.).
- **Streaming into message attachments** — Render structured data (tables, - **Streaming into message attachments** — Render structured data (tables,
JSON, code blocks) with better formatting. JSON, code blocks) with better formatting.
- **Conversation export** — Allow users to export an AI thread as markdown.