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
> **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 <url> 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 <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
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 <name>`)
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 <prompt>` 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 <prompt>` works as a fallback. Behaviour is identical.
**Full syntax:**
```
/ai <prompt>
— Send a prompt using the default model (current session context).
/ai model <name> <prompt>
— 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 <prompt> — Chat with default model
/ai persona — Show current active persona
/ai persona list — List all available personas with descriptions
/ai persona <name> — 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 <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 |
| 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.