feat: enhance Mattermost integration with focused context for standups and improved channel filtering

This commit is contained in:
2026-05-20 07:47:19 -06:00
parent 3d4da1919a
commit ee35f70c3e
6 changed files with 142 additions and 15 deletions

View File

@@ -26,6 +26,7 @@ Mattermost is the current live communication connector.
- Latest-message requests must refresh Mattermost before answering. - Latest-message requests must refresh Mattermost before answering.
- Latest-message requests are read-first. The agent may identify a memory update candidate, but should not edit `project-knowledge/` from the latest-message command unless the user explicitly asks to promote the fact. - Latest-message requests are read-first. The agent may identify a memory update candidate, but should not edit `project-knowledge/` from the latest-message command unless the user explicitly asks to promote the fact.
- Standup generation is a separate required-refresh flow: it must fetch Mattermost before drafting, even though general prompts should not sync automatically. - Standup generation is a separate required-refresh flow: it must fetch Mattermost before drafting, even though general prompts should not sync automatically.
- Standup reads should use the focused reader mode, `scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`, which reads date-bucketed previous-workday/today records and should use the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Avoid loading broad mirror `latest.md` into standup prompts because it may include stale or unrelated channels and waste tokens. Keep project-specific channel names out of reusable connector code.
- If the proxy mirror is running, treat it as fresher than legacy `mattermost-latest.md` / generated JSONL. Do not ignore mirror evidence merely because a legacy sync command also ran. - If the proxy mirror is running, treat it as fresher than legacy `mattermost-latest.md` / generated JSONL. Do not ignore mirror evidence merely because a legacy sync command also ran.
- Do not refresh Mattermost just because a prompt mentions a manager or stakeholder. - Do not refresh Mattermost just because a prompt mentions a manager or stakeholder.
- Treat document review, message polishing, translation, and "does this align with Jeff's expectations?" prompts as normal drafting tasks unless the user explicitly asks for the latest message or fresh Mattermost evidence. - Treat document review, message polishing, translation, and "does this align with Jeff's expectations?" prompts as normal drafting tasks unless the user explicitly asks for the latest message or fresh Mattermost evidence.

View File

@@ -24,6 +24,7 @@ It keeps Fidelity-specific context, integrations, commands, and skills separate
- Historical archive: Slack export - Historical archive: Slack export
- Preferred channel naming: readable channel names instead of raw IDs - Preferred channel naming: readable channel names instead of raw IDs
- Current high-signal channel: `fidelity-preguntas` - Current high-signal channel: `fidelity-preguntas`
- Focused Mattermost context for standups/latest project reads should come from configured profile/environment channels, not hardcoded connector defaults. For this profile, the useful context-channel set is currently `fidelity-preguntas`, `fidelity-standup`, `fidelity-code-review`, `fidelity-interface-meetings-on-calendar-outlook-team-etc`, and `dm-david--jeff`; keep that list in local `.env` as `AIW_MATTERMOST_CONTEXT_CHANNELS` or an equivalent profile setup when using the reusable Mattermost reader.
Compatibility environment variables: Compatibility environment variables:
@@ -39,6 +40,7 @@ Generic variables should be preferred for new setup:
- `AIW_SLACK_EXPORT_PATH` - `AIW_SLACK_EXPORT_PATH`
- `AIW_CHANNEL_PREFIX=fidelity` - `AIW_CHANNEL_PREFIX=fidelity`
- `AIW_PROJECT_PROFILE=fidelity` - `AIW_PROJECT_PROFILE=fidelity`
- `AIW_MATTERMOST_CONTEXT_CHANNELS=fidelity-preguntas,fidelity-standup,fidelity-code-review,fidelity-interface-meetings-on-calendar-outlook-team-etc,dm-david--jeff`
--- ---

View File

@@ -9,7 +9,7 @@ Generate a standup update for the active project profile.
## Required refresh ## Required refresh
- At the start of the day, fetch or read refreshed Mattermost evidence before drafting. Prefer the local proxy mirror through `scripts/mattermost-proxy/read-context.py` when it exists; legacy sync output is fallback evidence. - At the start of the day, fetch or read refreshed Mattermost evidence before drafting. Prefer the local proxy mirror through `scripts/mattermost-proxy/read-context.py` when it exists; legacy sync output is fallback evidence.
- Fetch both latest available messages and previous-workday activity when the connector supports both modes. - Fetch focused standup evidence with `python3 scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`; this reads previous-workday and today records from date-bucketed mirror files and should filter through the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Do not read broad `latest.md` for standups unless the focused date-bucketed view is unavailable and you explicitly label the fallback as broad/noisy.
- If Mattermost refresh fails, say so internally and use only saved workspace memory with clear caution; do not invent fresher context. - If Mattermost refresh fails, say so internally and use only saved workspace memory with clear caution; do not invent fresher context.
- Do not skip communication refresh for standup just to reduce latency, because stale standups cost more time to correct later. - Do not skip communication refresh for standup just to reduce latency, because stale standups cost more time to correct later.

View File

@@ -19,6 +19,13 @@ MATTERMOST_MIRROR_LATEST_LIMIT=200
# Optional channel allowlist. Comma-separated channel IDs. Empty means all captured channels. # Optional channel allowlist. Comma-separated channel IDs. Empty means all captured channels.
MATTERMOST_MIRROR_CHANNEL_IDS= MATTERMOST_MIRROR_CHANNEL_IDS=
# Optional AI context channel filter for reader commands such as:
# python3 scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD
# Use readable channel names or channel IDs. Keep project-specific values in your local .env
# or active profile setup, not in reusable scripts.
# Example: project-main,project-standup,dm-you--manager
AIW_MATTERMOST_CONTEXT_CHANNELS=
# Write compact raw REST/WebSocket evidence in addition to normalized messages. # Write compact raw REST/WebSocket evidence in addition to normalized messages.
# Keep disabled by default to avoid large files. # Keep disabled by default to avoid large files.
MATTERMOST_MIRROR_WRITE_RAW=0 MATTERMOST_MIRROR_WRITE_RAW=0

View File

@@ -69,6 +69,30 @@ and `threads/...` when a single discussion thread is the relevant evidence.
This mirrors Slack's export pattern of one folder per conversation with one file This mirrors Slack's export pattern of one folder per conversation with one file
per date, while adding Mattermost-specific thread views. per date, while adding Mattermost-specific thread views.
For standup generation, prefer the focused reader instead of loading broad
`latest.md` directly:
```bash
python3 scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD
```
`standup` mode reads only date-bucketed records for the previous workday and
today. To avoid spending tokens on unrelated channels or stale global `latest.md`
content, configure project-specific context channels in the connector-local
`.env` or pass them explicitly. Keep those channel values out of reusable scripts.
```bash
AIW_MATTERMOST_CONTEXT_CHANNELS="project-main,project-standup,dm-you--manager" \
python3 scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD
python3 scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD \
--channels "project-main,project-standup,dm-you--manager"
```
If no context channel filter is configured, `standup` mode still avoids
`latest.md` and reads date-bucketed records only, but it will include all mirrored
channels for those dates.
Direct-message channels are labeled as `dm-<user-a>--<user-b>` when the mirror Direct-message channels are labeled as `dm-<user-a>--<user-b>` when the mirror
has seen enough user metadata to resolve the Mattermost channel ID. Group DMs has seen enough user metadata to resolve the Mattermost channel ID. Group DMs
use `group-...`. If a DM was first captured before the relevant user metadata use `group-...`. If a DM was first captured before the relevant user metadata
@@ -119,6 +143,7 @@ Each line in the normalized JSONL contains:
- `MATTERMOST_MIRROR_CHANNEL_IDS`: optional comma-separated channel ID allowlist. - `MATTERMOST_MIRROR_CHANNEL_IDS`: optional comma-separated channel ID allowlist.
- `MATTERMOST_MIRROR_WRITE_RAW`: set to `1` to save compact raw REST/WebSocket evidence. - `MATTERMOST_MIRROR_WRITE_RAW`: set to `1` to save compact raw REST/WebSocket evidence.
- `MATTERMOST_APP_PATH`: Mattermost Desktop `.app` bundle path. - `MATTERMOST_APP_PATH`: Mattermost Desktop `.app` bundle path.
- `AIW_MATTERMOST_PROJECT_CHANNELS`: optional comma-separated channel names or IDs for focused standup reads.
## Troubleshooting ## Troubleshooting

View File

@@ -11,6 +11,8 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import shlex
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -19,6 +21,35 @@ ROOT = Path(__file__).resolve().parents[2]
MIRROR_DIR = ROOT / "ai" / "inbox" / "mattermost-mirror" MIRROR_DIR = ROOT / "ai" / "inbox" / "mattermost-mirror"
LEGACY_LATEST = ROOT / "ai" / "inbox" / "mattermost-latest.md" LEGACY_LATEST = ROOT / "ai" / "inbox" / "mattermost-latest.md"
LEGACY_GENERATED = ROOT / "scripts" / "mattermost" / "generated" / "mattermost_context.jsonl" LEGACY_GENERATED = ROOT / "scripts" / "mattermost" / "generated" / "mattermost_context.jsonl"
LOCAL_ENV = Path(__file__).resolve().parent / ".env"
def load_local_env(path: Path = LOCAL_ENV) -> None:
"""Load simple KEY=VALUE pairs from the connector-local .env.
Existing process environment values win. This keeps the reusable reader
project-agnostic while allowing each workspace/profile to provide its own
channel filters without hardcoding them in Python.
"""
if not path.is_file():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[len("export ") :].strip()
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if not key or key in os.environ:
continue
try:
parsed = shlex.split(value, comments=False, posix=True)
value = parsed[0] if parsed else ""
except ValueError:
value = value.strip('"\'')
os.environ[key] = value
def previous_workday(today: date) -> date: def previous_workday(today: date) -> date:
@@ -54,6 +85,35 @@ def read_jsonl(path: Path) -> list[dict]:
return records return records
def parse_channels(raw: str | None) -> set[str]:
if not raw:
env_raw = os.getenv("AIW_MATTERMOST_CONTEXT_CHANNELS", "") or os.getenv(
"AIW_MATTERMOST_PROJECT_CHANNELS", ""
)
raw = env_raw
if not raw:
return set()
return {item.strip() for item in raw.split(",") if item.strip()}
def filter_channels(records: list[dict], channels: set[str] | None) -> list[dict]:
if not channels:
return records
lowered = {channel.lower() for channel in channels}
return [
record
for record in records
if str(record.get("channel_name", "")).lower() in lowered
or str(record.get("channel_id", "")).lower() in lowered
]
def trim_records(records: list[dict], limit: int | None) -> list[dict]:
if limit is None or limit <= 0 or len(records) <= limit:
return records
return records[-limit:]
def print_jsonl(records: list[dict]) -> bool: def print_jsonl(records: list[dict]) -> bool:
if not records: if not records:
return False return False
@@ -81,24 +141,46 @@ def mode_latest() -> None:
fallback() fallback()
def mode_previous_workday(today_raw: str | None) -> None: def print_records_section(title: str, records: list[dict], limit: int | None = None) -> bool:
print(title)
records = trim_records(records, limit)
if print_jsonl(records):
return True
print("No matching Mattermost mirror records.")
return False
def mode_previous_workday(today_raw: str | None, channels: set[str] | None = None, limit: int | None = None) -> None:
today = datetime.strptime(today_raw, "%Y-%m-%d").date() if today_raw else date.today() today = datetime.strptime(today_raw, "%Y-%m-%d").date() if today_raw else date.today()
day = previous_workday(today) day = previous_workday(today)
path = daily_by_date_path(day) path = daily_by_date_path(day)
print(f"## Mattermost mirror previous-workday context ({day.isoformat()})") records = filter_channels(read_jsonl(path), channels)
records = read_jsonl(path) if print_records_section(f"## Mattermost mirror previous-workday context ({day.isoformat()})", records, limit):
if print_jsonl(records):
return return
print("No proxy mirror previous-workday context available; falling back to latest context.") if channels:
mode_latest() print("Filtered to project channels: " + ", ".join(sorted(channels)))
print("No proxy mirror previous-workday project context available; not falling back to broad latest context.")
def mode_standup(today_raw: str | None) -> None: def mode_standup(today_raw: str | None, channels: set[str], limit: int | None) -> None:
mode_previous_workday(today_raw) """Print a compact standup-focused view.
print("\n## Mattermost mirror latest context")
latest_md = MIRROR_DIR / "latest.md" Standup mode intentionally avoids latest.md because latest.md is a bounded
if latest_md.is_file() and latest_md.stat().st_size > 0: global window and can contain stale or unrelated channels. Use date-bucketed
print(latest_md.read_text(encoding="utf-8")) mirror files filtered to known project channels instead.
"""
today = datetime.strptime(today_raw, "%Y-%m-%d").date() if today_raw else date.today()
day = previous_workday(today)
previous_records = filter_channels(read_jsonl(daily_by_date_path(day)), channels)
today_records = filter_channels(read_jsonl(daily_by_date_path(today)), channels)
print("## Mattermost mirror standup context")
if channels:
print("Filtered to configured context channels: " + ", ".join(sorted(channels)))
else:
print("No context channel filter configured; using all mirrored date-bucket records.")
print_records_section(f"\n### Previous workday ({day.isoformat()})", previous_records, limit)
print_records_section(f"\n### Today so far ({today.isoformat()})", today_records, limit)
def mode_focused() -> None: def mode_focused() -> None:
@@ -116,17 +198,27 @@ def mode_focused() -> None:
def main() -> None: def main() -> None:
load_local_env()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["latest", "previous-workday", "standup", "focused"], default="latest") parser.add_argument("--mode", choices=["latest", "previous-workday", "standup", "focused"], default="latest")
parser.add_argument("--today", default="") parser.add_argument("--today", default="")
parser.add_argument(
"--channels",
default="",
help="Comma-separated channel names or IDs. Defaults to AIW_MATTERMOST_CONTEXT_CHANNELS from environment/.env when set.",
)
parser.add_argument("--limit", type=int, default=80, help="Max records per section; use 0 for no limit.")
args = parser.parse_args() args = parser.parse_args()
channels = parse_channels(args.channels or None)
limit = args.limit if args.limit > 0 else None
if args.mode == "latest": if args.mode == "latest":
mode_latest() mode_latest()
elif args.mode == "previous-workday": elif args.mode == "previous-workday":
mode_previous_workday(args.today or None) mode_previous_workday(args.today or None, channels=None, limit=limit)
elif args.mode == "standup": elif args.mode == "standup":
mode_standup(args.today or None) mode_standup(args.today or None, channels, limit)
elif args.mode == "focused": elif args.mode == "focused":
mode_focused() mode_focused()