feat: Implement Mattermost sync functionality and enhance workspace context management
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Local virtual environments
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Mattermost raw inbox artifacts
|
||||||
|
ai/inbox/mattermost-latest.md
|
||||||
|
ai/inbox/mattermost-*.md
|
||||||
|
ai/inbox/mattermost-status.json
|
||||||
|
|
||||||
|
# Workspace-local Mattermost runtime artifacts
|
||||||
|
scripts/mattermost/.env
|
||||||
|
scripts/mattermost/.venv/
|
||||||
|
scripts/mattermost/generated/*
|
||||||
|
!scripts/mattermost/generated/.gitkeep
|
||||||
28
.opencode/commands/mattermost-sync.md
Normal file
28
.opencode/commands/mattermost-sync.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
description: Force a Mattermost sync using the configured local script
|
||||||
|
---
|
||||||
|
|
||||||
|
Use the configured Mattermost sync command to fetch fresh communication context.
|
||||||
|
|
||||||
|
Preferred command sources:
|
||||||
|
|
||||||
|
- `FIDELITY_MATTERMOST_SYNC_CMD`
|
||||||
|
- fallback: `bash scripts/mattermost/sync.sh`
|
||||||
|
|
||||||
|
Run the command and use its output as fresh communication context:
|
||||||
|
|
||||||
|
!`if [ -n "$FIDELITY_MATTERMOST_SYNC_CMD" ]; then bash -lc "$FIDELITY_MATTERMOST_SYNC_CMD"; elif [ -f scripts/mattermost/sync.sh ]; then bash scripts/mattermost/sync.sh; else echo "No Mattermost sync command is configured."; fi`
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
- if the command fails, stop there and do not edit any workspace files
|
||||||
|
- if no `ai/inbox/mattermost-latest.md` exists after the sync attempt, do not update logs or stable context
|
||||||
|
- inspect `ai/inbox/mattermost-latest.md` if it exists
|
||||||
|
- decide whether the new information belongs in today's log, current state, or stable project context
|
||||||
|
- update the workspace only if the sync succeeded and durable facts were learned
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
1. What was synchronized
|
||||||
|
2. Which files were updated
|
||||||
|
3. What still needs human confirmation
|
||||||
120
.opencode/plugins/mattermost-inbox.js
Normal file
120
.opencode/plugins/mattermost-inbox.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
async function safeRead(filePath) {
|
||||||
|
try {
|
||||||
|
return await readFile(filePath, "utf8")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSyncCommand(directory) {
|
||||||
|
const configured = process.env.FIDELITY_MATTERMOST_SYNC_CMD?.trim()
|
||||||
|
if (configured) return configured
|
||||||
|
|
||||||
|
const fallbackScript = path.join(directory, "scripts/mattermost/sync.sh")
|
||||||
|
try {
|
||||||
|
await access(fallbackScript)
|
||||||
|
return `bash "${fallbackScript}"`
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MattermostInbox = async ({ $, directory, client }) => {
|
||||||
|
let lastSyncAt = 0
|
||||||
|
|
||||||
|
async function writeStatus(statusPath, data) {
|
||||||
|
await mkdir(path.dirname(statusPath), { recursive: true })
|
||||||
|
await writeFile(statusPath, `${JSON.stringify(data, null, 2)}\n`, "utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sync(reason) {
|
||||||
|
const command = await resolveSyncCommand(directory)
|
||||||
|
if (!command) return
|
||||||
|
|
||||||
|
const intervalMinutes = Number.parseInt(
|
||||||
|
process.env.FIDELITY_MATTERMOST_SYNC_INTERVAL_MINUTES || "15",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
const minIntervalMs = Math.max(1, Number.isNaN(intervalMinutes) ? 15 : intervalMinutes) * 60 * 1000
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (now - lastSyncAt < minIntervalMs) return
|
||||||
|
lastSyncAt = now
|
||||||
|
|
||||||
|
const inboxDir = path.join(directory, "ai/inbox")
|
||||||
|
const latestPath = path.join(inboxDir, "mattermost-latest.md")
|
||||||
|
const statusPath = path.join(inboxDir, "mattermost-status.json")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(inboxDir, { recursive: true })
|
||||||
|
const output = (await $`bash -lc ${command}`.text()).trim()
|
||||||
|
const previous = await safeRead(latestPath)
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
if (previous !== `${output}\n` && previous !== output) {
|
||||||
|
const stamp = nowIso().replace(/[:]/g, "-")
|
||||||
|
const snapshotPath = path.join(inboxDir, `mattermost-${stamp}.md`)
|
||||||
|
await writeFile(latestPath, `${output}\n`, "utf8")
|
||||||
|
await writeFile(snapshotPath, `${output}\n`, "utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeStatus(statusPath, {
|
||||||
|
syncedAt: nowIso(),
|
||||||
|
reason,
|
||||||
|
changed: previous !== `${output}\n` && previous !== output,
|
||||||
|
commandConfigured: true,
|
||||||
|
commandSource: process.env.FIDELITY_MATTERMOST_SYNC_CMD?.trim() ? "env" : "workspace-default",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await writeStatus(statusPath, {
|
||||||
|
syncedAt: nowIso(),
|
||||||
|
reason,
|
||||||
|
changed: false,
|
||||||
|
commandConfigured: true,
|
||||||
|
commandSource: process.env.FIDELITY_MATTERMOST_SYNC_CMD?.trim() ? "env" : "workspace-default",
|
||||||
|
note: "Sync command returned no output.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await writeStatus(statusPath, {
|
||||||
|
syncedAt: nowIso(),
|
||||||
|
reason,
|
||||||
|
changed: false,
|
||||||
|
commandConfigured: true,
|
||||||
|
commandSource: process.env.FIDELITY_MATTERMOST_SYNC_CMD?.trim() ? "env" : "workspace-default",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.app.log({
|
||||||
|
body: {
|
||||||
|
service: "mattermost-inbox",
|
||||||
|
level: "warn",
|
||||||
|
message: "Mattermost sync failed",
|
||||||
|
extra: {
|
||||||
|
reason,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: async ({ event }) => {
|
||||||
|
if (event.type === "session.created") {
|
||||||
|
await sync("session.created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "tui.prompt.append") {
|
||||||
|
await sync("tui.prompt.append")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ These are also loaded through `opencode.json`.
|
|||||||
|
|
||||||
- Assume the workspace may contain stale context until checked.
|
- Assume the workspace may contain stale context until checked.
|
||||||
- Before answering questions that depend on current work state, inspect `ai/state/current.md` and the latest relevant log under `ai/logs/`.
|
- Before answering questions that depend on current work state, inspect `ai/state/current.md` and the latest relevant log under `ai/logs/`.
|
||||||
|
- If `ai/inbox/mattermost-latest.md` exists, inspect it for fresher communication context before answering standup, status, or manager-message prompts.
|
||||||
|
- If a sync command, extraction script, or inbox refresh fails, do not update logs, state, or context files from that failed attempt.
|
||||||
|
- Treat sync failures as operational errors, not project context.
|
||||||
- If the user provides durable new facts, update the appropriate context files instead of leaving the new information only in chat history.
|
- If the user provides durable new facts, update the appropriate context files instead of leaving the new information only in chat history.
|
||||||
- If a previous context file is now stale or inaccurate, update that file directly.
|
- If a previous context file is now stale or inaccurate, update that file directly.
|
||||||
- Prefer correcting canonical context over appending contradictory notes.
|
- Prefer correcting canonical context over appending contradictory notes.
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -157,8 +157,24 @@ Recommended usage:
|
|||||||
Project commands live under `.opencode/commands/` and are intended to:
|
Project commands live under `.opencode/commands/` and are intended to:
|
||||||
|
|
||||||
- load the baseline Fidelity context
|
- load the baseline Fidelity context
|
||||||
|
- sync Mattermost context into the workspace inbox
|
||||||
- draft standups
|
- draft standups
|
||||||
- draft Jeff updates
|
- draft Jeff updates
|
||||||
- convert rough notes into daily log updates
|
- convert rough notes into daily log updates
|
||||||
|
|
||||||
This keeps AI output tied to the latest workspace state instead of relying on chat memory alone.
|
This keeps AI output tied to the latest workspace state instead of relying on chat memory alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mattermost Memory Flow
|
||||||
|
|
||||||
|
This workspace supports a live-memory pattern for Mattermost.
|
||||||
|
|
||||||
|
Recommended setup:
|
||||||
|
|
||||||
|
1. Use the workspace-local script at `scripts/mattermost/sync.sh`, or override it with `FIDELITY_MATTERMOST_SYNC_CMD`.
|
||||||
|
2. Let OpenCode run with the project plugins enabled.
|
||||||
|
3. The Mattermost inbox plugin will periodically refresh `ai/inbox/mattermost-latest.md`.
|
||||||
|
4. Promote durable facts from the inbox into `ai/logs/`, `ai/state/`, and `ai/context/`.
|
||||||
|
|
||||||
|
Use `/mattermost-sync` when you want to force a refresh manually.
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ When drafting messages for Jeff:
|
|||||||
|
|
||||||
- Treat workspace files as persistent memory, not just reference notes
|
- Treat workspace files as persistent memory, not just reference notes
|
||||||
- Before answering prompts about current work, verify `ai/state/current.md` and the latest relevant log in `ai/logs/`
|
- Before answering prompts about current work, verify `ai/state/current.md` and the latest relevant log in `ai/logs/`
|
||||||
|
- If `ai/inbox/mattermost-latest.md` exists, check it before answering prompts about current status, standups, or supervisor communication
|
||||||
|
- If a Mattermost sync or other context-ingestion step fails, do not update `ai/logs/`, `ai/state/`, or stable context files based on that failure
|
||||||
|
- Do not record missing dependencies, failed sync attempts, or missing inbox files as project facts unless the user explicitly asks to track the operational issue
|
||||||
- When the user shares relevant new information, update today's log if the information belongs to the daily record
|
- When the user shares relevant new information, update today's log if the information belongs to the daily record
|
||||||
- When the user corrects or changes stable context, update the canonical file directly:
|
- When the user corrects or changes stable context, update the canonical file directly:
|
||||||
- `ai/state/current.md` for current focus or active concerns
|
- `ai/state/current.md` for current focus or active concerns
|
||||||
@@ -79,7 +82,7 @@ For day-to-day prompts in this workspace:
|
|||||||
|
|
||||||
1. Verify the latest relevant context.
|
1. Verify the latest relevant context.
|
||||||
2. Decide whether the prompt introduces new durable information.
|
2. Decide whether the prompt introduces new durable information.
|
||||||
3. Update the workspace when needed.
|
3. Update the workspace when needed, but never from a failed sync or failed tool run.
|
||||||
4. Then answer using the refreshed context.
|
4. Then answer using the refreshed context.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
1
ai/inbox/.gitkeep
Normal file
1
ai/inbox/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
11
ai/inbox/README.md
Normal file
11
ai/inbox/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Mattermost Inbox
|
||||||
|
|
||||||
|
This directory stores raw or semi-processed communication captured from Mattermost before it is promoted into the persistent workspace context.
|
||||||
|
|
||||||
|
Recommended usage:
|
||||||
|
|
||||||
|
- `mattermost-latest.md` contains the most recent capture
|
||||||
|
- timestamped snapshots can be stored here if needed
|
||||||
|
- durable facts should be promoted into `ai/logs/`, `ai/state/`, or `ai/context/`
|
||||||
|
|
||||||
|
This directory is intentionally treated as an inbox, not as the final source of truth.
|
||||||
@@ -2,6 +2,43 @@
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"default_agent": "fidelity",
|
"default_agent": "fidelity",
|
||||||
"share": "manual",
|
"share": "manual",
|
||||||
|
"permission": {
|
||||||
|
"*": "ask",
|
||||||
|
"read": {
|
||||||
|
"*": "allow",
|
||||||
|
"*.env": "deny",
|
||||||
|
"*.env.*": "deny",
|
||||||
|
"*.env.example": "allow"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"*": "allow",
|
||||||
|
".git/*": "deny"
|
||||||
|
},
|
||||||
|
"glob": "allow",
|
||||||
|
"grep": "allow",
|
||||||
|
"list": "allow",
|
||||||
|
"lsp": "allow",
|
||||||
|
"task": "allow",
|
||||||
|
"question": "allow",
|
||||||
|
"skill": "allow",
|
||||||
|
"todoread": "allow",
|
||||||
|
"todowrite": "allow",
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"rm *": "deny",
|
||||||
|
"sudo *": "deny",
|
||||||
|
"git reset *": "deny",
|
||||||
|
"git clean *": "deny",
|
||||||
|
"git push *": "deny",
|
||||||
|
"git commit *": "ask",
|
||||||
|
"git checkout *": "ask"
|
||||||
|
},
|
||||||
|
"webfetch": "ask",
|
||||||
|
"websearch": "ask",
|
||||||
|
"codesearch": "ask",
|
||||||
|
"external_directory": "ask",
|
||||||
|
"doom_loop": "ask"
|
||||||
|
},
|
||||||
"instructions": [
|
"instructions": [
|
||||||
"./README.md",
|
"./README.md",
|
||||||
"./ai/context/project.md",
|
"./ai/context/project.md",
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
# Scripts
|
# Scripts
|
||||||
|
|
||||||
This directory is reserved for future helpers that automate:
|
This directory contains helpers that automate:
|
||||||
|
|
||||||
- context aggregation
|
- context aggregation
|
||||||
- standup generation
|
- standup generation
|
||||||
- manager update drafting
|
- manager update drafting
|
||||||
- Mattermost-ready message formatting
|
- Mattermost-ready message formatting
|
||||||
|
|
||||||
No automation has been implemented yet. The current workflow is document-driven.
|
The default workspace Mattermost extractor now lives in:
|
||||||
|
|
||||||
|
- `scripts/mattermost/`
|
||||||
|
|
||||||
|
Recommended default command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/mattermost/sync.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrap command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/mattermost/bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The current Mattermost extractor is stdlib-only and does not require installing `requests`.
|
||||||
|
|
||||||
|
If you still want to override it with another script, expose that to OpenCode with:
|
||||||
|
|
||||||
|
- `FIDELITY_MATTERMOST_SYNC_CMD`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FIDELITY_MATTERMOST_SYNC_CMD="/absolute/path/to/your-mattermost-sync-script"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
- print the latest relevant Mattermost context to stdout
|
||||||
|
- avoid interactive prompts
|
||||||
|
- return a non-zero exit code on failure
|
||||||
|
|
||||||
|
OpenCode can then use that output to refresh `ai/inbox/mattermost-latest.md` proactively.
|
||||||
|
|||||||
8
scripts/mattermost/.env.example
Normal file
8
scripts/mattermost/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
MATTERMOST_URL=https://tu-mattermost.example.com
|
||||||
|
MATTERMOST_TOKEN=tu_personal_access_token
|
||||||
|
CHANNEL_IDS=canal_id_1,canal_id_2
|
||||||
|
MESSAGE_WINDOW_HOURS=24
|
||||||
|
MAX_MESSAGES=200
|
||||||
|
MATTERMOST_CA_BUNDLE=ca.pem
|
||||||
|
MATTERMOST_SKIP_TLS_VERIFY=false
|
||||||
|
MATTERMOST_OUTPUT_FILE=generated/mattermost_context.jsonl
|
||||||
5
scripts/mattermost/.gitignore
vendored
Normal file
5
scripts/mattermost/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
generated/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
52
scripts/mattermost/README.md
Normal file
52
scripts/mattermost/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Mattermost Sync
|
||||||
|
|
||||||
|
This directory contains the workspace-local Mattermost extractor used by OpenCode to refresh communication context.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `mattermost_context.py`
|
||||||
|
Extracts recent Mattermost messages as JSONL.
|
||||||
|
- `sync.sh`
|
||||||
|
Stable wrapper intended to be called by OpenCode.
|
||||||
|
- `.env.example`
|
||||||
|
Example configuration without secrets.
|
||||||
|
- `requirements.txt`
|
||||||
|
Reserved for future optional dependencies.
|
||||||
|
|
||||||
|
## Recommended Setup
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env`.
|
||||||
|
2. Fill in your Mattermost values.
|
||||||
|
3. Create a local virtual environment if you want an isolated runtime.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts/mattermost
|
||||||
|
bash bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Manual run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/mattermost/sync.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenCode can use this script directly. If `FIDELITY_MATTERMOST_SYNC_CMD` is not set, the workspace plugins will fall back to this wrapper automatically.
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
You can initialize the local runtime with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/mattermost/bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
|
||||||
|
- create `.venv/` if missing
|
||||||
|
- create `.env` from `.env.example` if needed
|
||||||
|
|
||||||
|
The current extractor uses only the Python standard library, so no third-party install is required.
|
||||||
29
scripts/mattermost/bootstrap.sh
Normal file
29
scripts/mattermost/bootstrap.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||||
|
|
||||||
|
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
|
||||||
|
echo "Python interpreter not found: $PYTHON_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$VENV_DIR" ]]; then
|
||||||
|
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SCRIPT_DIR/.env" ]]; then
|
||||||
|
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
|
||||||
|
echo "Created $SCRIPT_DIR/.env from template. Fill in secrets before syncing."
|
||||||
|
else
|
||||||
|
echo "Using existing $SCRIPT_DIR/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Bootstrap complete."
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Review $SCRIPT_DIR/.env"
|
||||||
|
echo "2. Run: bash scripts/mattermost/sync.sh"
|
||||||
349
scripts/mattermost/mattermost_context.py
Normal file
349
scripts/mattermost/mattermost_context.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from urllib import error, parse, request
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_WINDOW_HOURS = 24
|
||||||
|
DEFAULT_MAX_MESSAGES = 200
|
||||||
|
MAX_PER_PAGE = 200
|
||||||
|
DEFAULT_OUTPUT_FILE = str(SCRIPT_DIR / "generated" / "mattermost_context.jsonl")
|
||||||
|
REQUEST_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("mattermost_context")
|
||||||
|
USER_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
MATTERMOST_URL = ""
|
||||||
|
CHANNEL_IDS: List[str] = []
|
||||||
|
WINDOW_HOURS = DEFAULT_WINDOW_HOURS
|
||||||
|
MAX_MESSAGES = DEFAULT_MAX_MESSAGES
|
||||||
|
CUTOFF_TIMESTAMP_MS = 0
|
||||||
|
OUTPUT_FILE = DEFAULT_OUTPUT_FILE
|
||||||
|
REQUEST_HEADERS: Dict[str, str] = {}
|
||||||
|
SSL_CONTEXT: ssl.SSLContext | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MattermostAPIError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool_env(name: str, default: bool = False) -> bool:
|
||||||
|
raw_value = os.getenv(name)
|
||||||
|
if raw_value is None:
|
||||||
|
return default
|
||||||
|
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv_file(path: Path | None = None) -> None:
|
||||||
|
dotenv_path = path or (SCRIPT_DIR / ".env")
|
||||||
|
if not dotenv_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
with dotenv_path.open("r", encoding="utf-8") as file_handle:
|
||||||
|
for raw_line in file_handle:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[len("export ") :].strip()
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||||||
|
value = value[1:-1]
|
||||||
|
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def require_env(name: str) -> str:
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_channel_ids(raw_value: str) -> List[str]:
|
||||||
|
normalized = raw_value.replace("\n", ",")
|
||||||
|
channel_ids = [item.strip() for item in normalized.split(",") if item.strip()]
|
||||||
|
if not channel_ids:
|
||||||
|
raise ValueError("CHANNEL_IDS must contain at least one channel id.")
|
||||||
|
return channel_ids
|
||||||
|
|
||||||
|
|
||||||
|
def build_ssl_context() -> ssl.SSLContext:
|
||||||
|
ca_bundle = os.getenv("MATTERMOST_CA_BUNDLE", "").strip()
|
||||||
|
skip_tls_verify = parse_bool_env("MATTERMOST_SKIP_TLS_VERIFY", default=False)
|
||||||
|
|
||||||
|
if skip_tls_verify:
|
||||||
|
LOGGER.warning("TLS certificate verification is disabled via MATTERMOST_SKIP_TLS_VERIFY.")
|
||||||
|
return ssl._create_unverified_context()
|
||||||
|
|
||||||
|
if ca_bundle:
|
||||||
|
LOGGER.info("Using custom CA bundle from MATTERMOST_CA_BUNDLE: %s", ca_bundle)
|
||||||
|
return ssl.create_default_context(cafile=ca_bundle)
|
||||||
|
|
||||||
|
return ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
def configure() -> None:
|
||||||
|
global MATTERMOST_URL, CHANNEL_IDS, WINDOW_HOURS, MAX_MESSAGES, CUTOFF_TIMESTAMP_MS, OUTPUT_FILE
|
||||||
|
global REQUEST_HEADERS, SSL_CONTEXT
|
||||||
|
|
||||||
|
load_dotenv_file()
|
||||||
|
MATTERMOST_URL = require_env("MATTERMOST_URL").rstrip("/")
|
||||||
|
token = require_env("MATTERMOST_TOKEN")
|
||||||
|
CHANNEL_IDS = parse_channel_ids(require_env("CHANNEL_IDS"))
|
||||||
|
WINDOW_HOURS = int(os.getenv("MESSAGE_WINDOW_HOURS", str(DEFAULT_WINDOW_HOURS)))
|
||||||
|
MAX_MESSAGES = int(os.getenv("MAX_MESSAGES", str(DEFAULT_MAX_MESSAGES)))
|
||||||
|
OUTPUT_FILE = os.getenv("MATTERMOST_OUTPUT_FILE", DEFAULT_OUTPUT_FILE).strip() or DEFAULT_OUTPUT_FILE
|
||||||
|
|
||||||
|
if WINDOW_HOURS <= 0:
|
||||||
|
raise ValueError("MESSAGE_WINDOW_HOURS must be greater than 0.")
|
||||||
|
if MAX_MESSAGES <= 0:
|
||||||
|
raise ValueError("MAX_MESSAGES must be greater than 0.")
|
||||||
|
|
||||||
|
cutoff = datetime.now().astimezone() - timedelta(hours=WINDOW_HOURS)
|
||||||
|
CUTOFF_TIMESTAMP_MS = int(cutoff.timestamp() * 1000)
|
||||||
|
|
||||||
|
REQUEST_HEADERS = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
SSL_CONTEXT = build_ssl_context()
|
||||||
|
|
||||||
|
|
||||||
|
def api_get_json(api_path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
||||||
|
query = f"?{parse.urlencode(params)}" if params else ""
|
||||||
|
url = f"{MATTERMOST_URL}{api_path}{query}"
|
||||||
|
req = request.Request(url, headers=REQUEST_HEADERS, method="GET")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=REQUEST_TIMEOUT, context=SSL_CONTEXT) as response:
|
||||||
|
charset = response.headers.get_content_charset() or "utf-8"
|
||||||
|
payload = response.read().decode(charset)
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
body = ""
|
||||||
|
raise MattermostAPIError(f"HTTP {exc.code} for {api_path}: {body or exc.reason}") from exc
|
||||||
|
except error.URLError as exc:
|
||||||
|
reason = exc.reason if hasattr(exc, "reason") else str(exc)
|
||||||
|
raise MattermostAPIError(f"Request failed for {api_path}: {reason}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(payload)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise MattermostAPIError(f"Invalid JSON returned by {api_path}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_posts(channel_id: str) -> List[Dict[str, Any]]:
|
||||||
|
collected: List[Dict[str, Any]] = []
|
||||||
|
page = 0
|
||||||
|
per_page = min(MAX_PER_PAGE, MAX_MESSAGES)
|
||||||
|
|
||||||
|
while len(collected) < MAX_MESSAGES:
|
||||||
|
payload = api_get_json(
|
||||||
|
f"/api/v4/channels/{channel_id}/posts",
|
||||||
|
{"page": page, "per_page": per_page},
|
||||||
|
)
|
||||||
|
order = payload.get("order", [])
|
||||||
|
posts_by_id = payload.get("posts", {})
|
||||||
|
|
||||||
|
if not order:
|
||||||
|
break
|
||||||
|
|
||||||
|
reached_cutoff = False
|
||||||
|
for post_id in order:
|
||||||
|
post = posts_by_id.get(post_id)
|
||||||
|
if not post:
|
||||||
|
continue
|
||||||
|
|
||||||
|
created_at = int(post.get("create_at", 0))
|
||||||
|
if created_at < CUTOFF_TIMESTAMP_MS:
|
||||||
|
reached_cutoff = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
collected.append(post)
|
||||||
|
if len(collected) >= MAX_MESSAGES:
|
||||||
|
break
|
||||||
|
|
||||||
|
if reached_cutoff or len(order) < per_page:
|
||||||
|
break
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
LOGGER.info("Fetched %s raw posts from channel %s", len(collected), channel_id)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_info(user_id: str) -> Dict[str, Any]:
|
||||||
|
if not user_id:
|
||||||
|
return {"id": "", "username": "unknown"}
|
||||||
|
|
||||||
|
if user_id in USER_CACHE:
|
||||||
|
return USER_CACHE[user_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_data = api_get_json(f"/api/v4/users/{user_id}")
|
||||||
|
except MattermostAPIError as exc:
|
||||||
|
LOGGER.error("Could not fetch user %s: %s", user_id, exc)
|
||||||
|
fallback = {"id": user_id, "username": user_id}
|
||||||
|
USER_CACHE[user_id] = fallback
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
USER_CACHE[user_id] = user_data
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_map(messages: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||||
|
user_map: Dict[str, str] = {}
|
||||||
|
user_ids = sorted({message["user_id"] for message in messages if message.get("user_id")})
|
||||||
|
|
||||||
|
for user_id in user_ids:
|
||||||
|
user_info = get_user_info(user_id)
|
||||||
|
username = (
|
||||||
|
user_info.get("username")
|
||||||
|
or user_info.get("nickname")
|
||||||
|
or user_info.get("first_name")
|
||||||
|
or user_id
|
||||||
|
)
|
||||||
|
user_map[user_id] = username
|
||||||
|
|
||||||
|
return user_map
|
||||||
|
|
||||||
|
|
||||||
|
def is_system_message(post: Dict[str, Any]) -> bool:
|
||||||
|
post_type = (post.get("type") or "").strip()
|
||||||
|
return post_type.startswith("system_")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_messages() -> List[Dict[str, Any]]:
|
||||||
|
all_messages: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for channel_id in CHANNEL_IDS:
|
||||||
|
raw_posts = get_channel_posts(channel_id)
|
||||||
|
for post in raw_posts:
|
||||||
|
if is_system_message(post):
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = (post.get("message") or "").strip()
|
||||||
|
if not message:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_messages.append(
|
||||||
|
{
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"channel_ref": channel_id,
|
||||||
|
"post_id": post.get("id", ""),
|
||||||
|
"user_id": post.get("user_id", ""),
|
||||||
|
"create_at": int(post.get("create_at", 0)),
|
||||||
|
"message": message.replace("\r\n", "\n"),
|
||||||
|
"root_id": post.get("root_id", ""),
|
||||||
|
"reply_count": int(post.get("reply_count", 0)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
all_messages.sort(key=lambda item: item["create_at"])
|
||||||
|
if len(all_messages) > MAX_MESSAGES:
|
||||||
|
all_messages = all_messages[-MAX_MESSAGES:]
|
||||||
|
|
||||||
|
user_map = build_user_map(all_messages)
|
||||||
|
for message in all_messages:
|
||||||
|
message["username"] = user_map.get(message["user_id"], message["user_id"] or "unknown")
|
||||||
|
|
||||||
|
LOGGER.info("Prepared %s messages after filtering", len(all_messages))
|
||||||
|
return all_messages
|
||||||
|
|
||||||
|
|
||||||
|
def format_messages(messages: List[Dict[str, Any]]) -> str:
|
||||||
|
lines: List[str] = []
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
timestamp = datetime.fromtimestamp(message["create_at"] / 1000).astimezone()
|
||||||
|
username = message.get("username", "unknown")
|
||||||
|
post_id = message.get("post_id", "")
|
||||||
|
root_id = message.get("root_id", "")
|
||||||
|
thread_id = root_id or post_id or "unknown"
|
||||||
|
reply_count = int(message.get("reply_count", 0))
|
||||||
|
|
||||||
|
if root_id:
|
||||||
|
message_kind = "thread_reply"
|
||||||
|
elif reply_count > 0:
|
||||||
|
message_kind = "thread_root"
|
||||||
|
else:
|
||||||
|
message_kind = "channel_post"
|
||||||
|
|
||||||
|
channel_ref = message.get("channel_ref", message.get("channel_id", "unknown"))
|
||||||
|
record = {
|
||||||
|
"source": "mattermost",
|
||||||
|
"channel": channel_ref,
|
||||||
|
"channel_id": message.get("channel_id", ""),
|
||||||
|
"post_id": post_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"root_id": root_id or None,
|
||||||
|
"type": message_kind,
|
||||||
|
"timestamp": timestamp.isoformat(),
|
||||||
|
"username": username,
|
||||||
|
"message": message["message"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if root_id:
|
||||||
|
record["reply_to"] = root_id
|
||||||
|
if reply_count > 0:
|
||||||
|
record["reply_count"] = reply_count
|
||||||
|
|
||||||
|
lines.append(json.dumps(record, ensure_ascii=False, sort_keys=False))
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_file(text: str) -> None:
|
||||||
|
output_path = Path(OUTPUT_FILE).expanduser().resolve()
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if text and not text.endswith("\n"):
|
||||||
|
text = f"{text}\n"
|
||||||
|
output_path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
try:
|
||||||
|
configure()
|
||||||
|
messages = extract_messages()
|
||||||
|
output = format_messages(messages)
|
||||||
|
print(output)
|
||||||
|
save_to_file(output)
|
||||||
|
LOGGER.info("Saved context to %s", OUTPUT_FILE)
|
||||||
|
except ValueError as exc:
|
||||||
|
LOGGER.error("%s", exc)
|
||||||
|
return 1
|
||||||
|
except MattermostAPIError as exc:
|
||||||
|
LOGGER.error("%s", exc)
|
||||||
|
return 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.exception("Unexpected error: %s", exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1
scripts/mattermost/requirements.txt
Normal file
1
scripts/mattermost/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# No third-party dependencies are currently required.
|
||||||
15
scripts/mattermost/sync.sh
Executable file
15
scripts/mattermost/sync.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
if [[ -x "$SCRIPT_DIR/.venv/bin/python" ]]; then
|
||||||
|
PYTHON_BIN="$SCRIPT_DIR/.venv/bin/python"
|
||||||
|
elif [[ -n "${PYTHON_BIN:-}" ]]; then
|
||||||
|
PYTHON_BIN="$PYTHON_BIN"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$PYTHON_BIN" "$SCRIPT_DIR/mattermost_context.py"
|
||||||
@@ -18,10 +18,11 @@ Use OpenCode as the daily AI entry point for this workspace without losing proje
|
|||||||
## Recommended Daily Sequence
|
## Recommended Daily Sequence
|
||||||
|
|
||||||
1. Run `/fidelity-context` at the start of the day.
|
1. Run `/fidelity-context` at the start of the day.
|
||||||
2. When new work happens on the main development machine, run `/sync-context ...` or `/log-note ...`.
|
2. If Mattermost sync is configured, let the inbox refresh automatically or run `/mattermost-sync`.
|
||||||
3. When you need a supervisor update, run `/jeff-update ...`.
|
3. When new work happens on the main development machine, run `/sync-context ...` or `/log-note ...`.
|
||||||
4. When you need polished English for Mattermost, run `/translate ...`.
|
4. When you need a supervisor update, run `/jeff-update ...`.
|
||||||
5. When you need a standup, run `/standup`.
|
5. When you need polished English for Mattermost, run `/translate ...`.
|
||||||
|
6. When you need a standup, run `/standup`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ Use OpenCode as the daily AI entry point for this workspace without losing proje
|
|||||||
- Project instructions load automatically from `opencode.json`.
|
- Project instructions load automatically from `opencode.json`.
|
||||||
- Root rules also load automatically from `AGENTS.md`.
|
- Root rules also load automatically from `AGENTS.md`.
|
||||||
- The main context command reads the stable workspace files plus today's log.
|
- The main context command reads the stable workspace files plus today's log.
|
||||||
|
- Mattermost context can be refreshed into `ai/inbox/` using your existing local sync script.
|
||||||
- Daily updates go back into the workspace, so later prompts inherit better context.
|
- Daily updates go back into the workspace, so later prompts inherit better context.
|
||||||
- The local compaction plugin helps preserve the most important workspace context during long sessions.
|
- The local compaction plugin helps preserve the most important workspace context during long sessions.
|
||||||
- This reduces reliance on fragile session memory.
|
- This reduces reliance on fragile session memory.
|
||||||
|
|||||||
Reference in New Issue
Block a user