diff --git a/agent-memory/integrations/communication-sources.md b/agent-memory/integrations/communication-sources.md index 7020608..b13fc10 100644 --- a/agent-memory/integrations/communication-sources.md +++ b/agent-memory/integrations/communication-sources.md @@ -26,6 +26,7 @@ Mattermost is the current live communication connector. - 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. - 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. - 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. diff --git a/profiles/fidelity/profile.md b/profiles/fidelity/profile.md index fcc6d4e..18c889e 100644 --- a/profiles/fidelity/profile.md +++ b/profiles/fidelity/profile.md @@ -24,6 +24,7 @@ It keeps Fidelity-specific context, integrations, commands, and skills separate - Historical archive: Slack export - Preferred channel naming: readable channel names instead of raw IDs - 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: @@ -39,6 +40,7 @@ Generic variables should be preferred for new setup: - `AIW_SLACK_EXPORT_PATH` - `AIW_CHANNEL_PREFIX=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` --- diff --git a/prompts/standup.md b/prompts/standup.md index 81c4eea..4b108d9 100644 --- a/prompts/standup.md +++ b/prompts/standup.md @@ -9,7 +9,7 @@ Generate a standup update for the active project profile. ## 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. -- 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. - Do not skip communication refresh for standup just to reduce latency, because stale standups cost more time to correct later. diff --git a/scripts/mattermost-proxy/.env.example b/scripts/mattermost-proxy/.env.example index a45dede..5fb8dd2 100644 --- a/scripts/mattermost-proxy/.env.example +++ b/scripts/mattermost-proxy/.env.example @@ -19,6 +19,13 @@ MATTERMOST_MIRROR_LATEST_LIMIT=200 # Optional channel allowlist. Comma-separated channel IDs. Empty means all captured channels. 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. # Keep disabled by default to avoid large files. MATTERMOST_MIRROR_WRITE_RAW=0 diff --git a/scripts/mattermost-proxy/README.md b/scripts/mattermost-proxy/README.md index 3a7567b..39d7983 100644 --- a/scripts/mattermost-proxy/README.md +++ b/scripts/mattermost-proxy/README.md @@ -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 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---` when the mirror 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 @@ -119,6 +143,7 @@ Each line in the normalized JSONL contains: - `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_APP_PATH`: Mattermost Desktop `.app` bundle path. +- `AIW_MATTERMOST_PROJECT_CHANNELS`: optional comma-separated channel names or IDs for focused standup reads. ## Troubleshooting diff --git a/scripts/mattermost-proxy/read-context.py b/scripts/mattermost-proxy/read-context.py index 16dcca1..0faaf57 100755 --- a/scripts/mattermost-proxy/read-context.py +++ b/scripts/mattermost-proxy/read-context.py @@ -11,6 +11,8 @@ from __future__ import annotations import argparse import json +import os +import shlex from datetime import date, datetime, timedelta from pathlib import Path @@ -19,6 +21,35 @@ ROOT = Path(__file__).resolve().parents[2] MIRROR_DIR = ROOT / "ai" / "inbox" / "mattermost-mirror" LEGACY_LATEST = ROOT / "ai" / "inbox" / "mattermost-latest.md" 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: @@ -54,6 +85,35 @@ def read_jsonl(path: Path) -> list[dict]: 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: if not records: return False @@ -81,24 +141,46 @@ def mode_latest() -> None: 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() day = previous_workday(today) path = daily_by_date_path(day) - print(f"## Mattermost mirror previous-workday context ({day.isoformat()})") - records = read_jsonl(path) - if print_jsonl(records): + records = filter_channels(read_jsonl(path), channels) + if print_records_section(f"## Mattermost mirror previous-workday context ({day.isoformat()})", records, limit): return - print("No proxy mirror previous-workday context available; falling back to latest context.") - mode_latest() + if channels: + 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: - mode_previous_workday(today_raw) - print("\n## Mattermost mirror latest context") - latest_md = MIRROR_DIR / "latest.md" - if latest_md.is_file() and latest_md.stat().st_size > 0: - print(latest_md.read_text(encoding="utf-8")) +def mode_standup(today_raw: str | None, channels: set[str], limit: int | None) -> None: + """Print a compact standup-focused view. + + Standup mode intentionally avoids latest.md because latest.md is a bounded + global window and can contain stale or unrelated channels. Use date-bucketed + 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: @@ -116,17 +198,27 @@ def mode_focused() -> None: def main() -> None: + load_local_env() + parser = argparse.ArgumentParser() parser.add_argument("--mode", choices=["latest", "previous-workday", "standup", "focused"], default="latest") 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() + channels = parse_channels(args.channels or None) + limit = args.limit if args.limit > 0 else None if args.mode == "latest": mode_latest() 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": - mode_standup(args.today or None) + mode_standup(args.today or None, channels, limit) elif args.mode == "focused": mode_focused()