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.
|
||||
- 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 a previous context file is now stale or inaccurate, update that file directly.
|
||||
- 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:
|
||||
|
||||
- load the baseline Fidelity context
|
||||
- sync Mattermost context into the workspace inbox
|
||||
- draft standups
|
||||
- draft Jeff 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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- 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 corrects or changes stable context, update the canonical file directly:
|
||||
- `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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
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",
|
||||
"default_agent": "fidelity",
|
||||
"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": [
|
||||
"./README.md",
|
||||
"./ai/context/project.md",
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
# Scripts
|
||||
|
||||
This directory is reserved for future helpers that automate:
|
||||
This directory contains helpers that automate:
|
||||
|
||||
- context aggregation
|
||||
- standup generation
|
||||
- manager update drafting
|
||||
- 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
|
||||
|
||||
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 ...`.
|
||||
3. When you need a supervisor update, run `/jeff-update ...`.
|
||||
4. When you need polished English for Mattermost, run `/translate ...`.
|
||||
5. When you need a standup, run `/standup`.
|
||||
2. If Mattermost sync is configured, let the inbox refresh automatically or run `/mattermost-sync`.
|
||||
3. When new work happens on the main development machine, run `/sync-context ...` or `/log-note ...`.
|
||||
4. When you need a supervisor update, run `/jeff-update ...`.
|
||||
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`.
|
||||
- Root rules also load automatically from `AGENTS.md`.
|
||||
- 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.
|
||||
- The local compaction plugin helps preserve the most important workspace context during long sessions.
|
||||
- This reduces reliance on fragile session memory.
|
||||
|
||||
Reference in New Issue
Block a user