diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd553c8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.opencode/commands/mattermost-sync.md b/.opencode/commands/mattermost-sync.md new file mode 100644 index 0000000..c26cda1 --- /dev/null +++ b/.opencode/commands/mattermost-sync.md @@ -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 diff --git a/.opencode/plugins/mattermost-inbox.js b/.opencode/plugins/mattermost-inbox.js new file mode 100644 index 0000000..a698f8d --- /dev/null +++ b/.opencode/plugins/mattermost-inbox.js @@ -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") + } + }, + } +} diff --git a/AGENTS.md b/AGENTS.md index 7969851..e12fafe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 8a6cba9..7d2f5e5 100644 --- a/README.md +++ b/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. diff --git a/ai/AGENTS.md b/ai/AGENTS.md index 571b8d8..138f564 100644 --- a/ai/AGENTS.md +++ b/ai/AGENTS.md @@ -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. --- diff --git a/ai/inbox/.gitkeep b/ai/inbox/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai/inbox/.gitkeep @@ -0,0 +1 @@ + diff --git a/ai/inbox/README.md b/ai/inbox/README.md new file mode 100644 index 0000000..a4d0052 --- /dev/null +++ b/ai/inbox/README.md @@ -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. diff --git a/opencode.json b/opencode.json index e9568dd..75ca570 100644 --- a/opencode.json +++ b/opencode.json @@ -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", diff --git a/scripts/README.md b/scripts/README.md index f236163..4abfb8d 100644 --- a/scripts/README.md +++ b/scripts/README.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. diff --git a/scripts/mattermost/.env.example b/scripts/mattermost/.env.example new file mode 100644 index 0000000..e70356e --- /dev/null +++ b/scripts/mattermost/.env.example @@ -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 diff --git a/scripts/mattermost/.gitignore b/scripts/mattermost/.gitignore new file mode 100644 index 0000000..6d865e5 --- /dev/null +++ b/scripts/mattermost/.gitignore @@ -0,0 +1,5 @@ +.env +.venv/ +generated/ +__pycache__/ +*.py[cod] diff --git a/scripts/mattermost/README.md b/scripts/mattermost/README.md new file mode 100644 index 0000000..31903ea --- /dev/null +++ b/scripts/mattermost/README.md @@ -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. diff --git a/scripts/mattermost/bootstrap.sh b/scripts/mattermost/bootstrap.sh new file mode 100644 index 0000000..4faa11a --- /dev/null +++ b/scripts/mattermost/bootstrap.sh @@ -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" diff --git a/scripts/mattermost/mattermost_context.py b/scripts/mattermost/mattermost_context.py new file mode 100644 index 0000000..5024511 --- /dev/null +++ b/scripts/mattermost/mattermost_context.py @@ -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()) diff --git a/scripts/mattermost/requirements.txt b/scripts/mattermost/requirements.txt new file mode 100644 index 0000000..1c9d878 --- /dev/null +++ b/scripts/mattermost/requirements.txt @@ -0,0 +1 @@ +# No third-party dependencies are currently required. diff --git a/scripts/mattermost/sync.sh b/scripts/mattermost/sync.sh new file mode 100755 index 0000000..906eaa6 --- /dev/null +++ b/scripts/mattermost/sync.sh @@ -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" diff --git a/workflows/opencode-entry.md b/workflows/opencode-entry.md index 02dafa2..c1b1ea7 100644 --- a/workflows/opencode-entry.md +++ b/workflows/opencode-entry.md @@ -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.