feat: Add previous workday mode to Mattermost extractor and enhance sync script

This commit is contained in:
2026-04-13 10:18:59 -06:00
parent e65471f3c2
commit 07e198a641
6 changed files with 132 additions and 16 deletions

View File

@@ -24,9 +24,9 @@ Read:
@knowledge/communication-rules.md @knowledge/communication-rules.md
@knowledge/agent-memory-rules.md @knowledge/agent-memory-rules.md
Yesterday's log, if present: Previous workday Mattermost context, if present:
!`y=$(date -v-1d +%F 2>/dev/null || python3 - <<'PY'\nfrom datetime import datetime, timedelta\nprint((datetime.now().astimezone() - timedelta(days=1)).strftime('%Y-%m-%d'))\nPY\n); if [ -f "ai/logs/$y.md" ]; then echo "$y"; cat "ai/logs/$y.md"; else echo "No log exists for yesterday."; fi` !`bash scripts/mattermost/sync.sh --previous-workday --today "$(date +%F)"`
Today's log, if present: Today's log, if present:
@@ -44,7 +44,8 @@ Before drafting:
- update workspace memory if the refreshed context introduced clear high-confidence project facts - update workspace memory if the refreshed context introduced clear high-confidence project facts
- prefer existing memory when the latest context is ambiguous - prefer existing memory when the latest context is ambiguous
- mention Jira IDs and approved titles when they map cleanly to yesterday's work - treat the previous workday Mattermost context as the source for the `Yesterday` section, even when the previous calendar day was a weekend, holiday, or OOO day
- mention Jira IDs and approved titles when they map cleanly to previous-work context
- prioritize story-based updates over side questions, memory refreshes, or manager-only context - prioritize story-based updates over side questions, memory refreshes, or manager-only context
- if documentation or root cause updates directly support a story, roll them into that story's update instead of listing them separately - if documentation or root cause updates directly support a story, roll them into that story's update instead of listing them separately
- exclude items that are not directly tied to a story unless they are true blockers - exclude items that are not directly tied to a story unless they are true blockers

View File

@@ -1,13 +1,15 @@
# Standup Prompt # Standup Prompt
Use `ai/state/current.md`, `ai/work-items/index.md`, the relevant files under `ai/work-items/`, `ai/state/work-items.md`, `ai/context/index.md`, `ai/context/project.md`, `ai/context/workstreams/index.md`, `ai/context/process/communication.md`, `ai/context/people/manager.md`, yesterday's log, today's log if present, and the latest available Mattermost context. Use `ai/state/current.md`, `ai/work-items/index.md`, the relevant files under `ai/work-items/`, `ai/state/work-items.md`, `ai/context/index.md`, `ai/context/project.md`, `ai/context/workstreams/index.md`, `ai/context/process/communication.md`, `ai/context/people/manager.md`, the previous workday Mattermost context, today's log if present, and the latest available Mattermost context.
Generate a standup update for an iOS engineer working on Fidelity. Generate a standup update for an iOS engineer working on Fidelity.
Requirements: Requirements:
- Use the most recent context only - Use the most recent context only
- Be specific about what was worked on yesterday - Be specific about what was worked on during the previous workday, not necessarily the previous calendar day
- On Mondays, use Friday's work context unless a later prior day has Mattermost activity
- If the previous calendar day has no work activity or is OOO/weekend, use the latest prior day with Mattermost activity
- Mention debugging findings only if they materially changed understanding - Mention debugging findings only if they materially changed understanding
- Clarify auth-dependent behavior when relevant - Clarify auth-dependent behavior when relevant
- Mention Jira IDs and approved titles when they are available and clearly tied to the reported work - Mention Jira IDs and approved titles when they are available and clearly tied to the reported work

View File

@@ -55,3 +55,9 @@ Recommended raw archive location:
- `archives/slack/export/` - `archives/slack/export/`
The importer can auto-detect `fidelity*` channels and auto-tune message selection for very large exports. The importer can auto-detect `fidelity*` channels and auto-tune message selection for very large exports.
The Mattermost extractor can also fetch the latest prior day with channel activity for standups. It starts from the previous calendar day and expands backward automatically when there is no activity, which covers Mondays, weekends, holidays, and OOO gaps.
```bash
bash scripts/mattermost/sync.sh --previous-workday
```

View File

@@ -60,6 +60,20 @@ 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. OpenCode can use this script directly. If `FIDELITY_MATTERMOST_SYNC_CMD` is not set, the workspace plugins will fall back to this wrapper automatically.
Previous workday mode for standups:
```bash
bash scripts/mattermost/sync.sh --previous-workday
```
This mode starts from the previous calendar day and expands backward until it finds a day with Mattermost activity in the configured channels. It handles Mondays, weekends, holidays, and OOO gaps without relying on workspace logs.
Useful options:
- `--today YYYY-MM-DD`
- `--max-lookback-days N`
- `--output-file PATH`
## Bootstrap ## Bootstrap
You can initialize the local runtime with: You can initialize the local runtime with:

View File

@@ -3,9 +3,11 @@
import json import json
import logging import logging
import os import os
import re
import ssl import ssl
import sys import sys
from datetime import datetime, timedelta from argparse import ArgumentParser, Namespace
from datetime import date, datetime, time, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
from urllib import error, parse, request from urllib import error, parse, request
@@ -17,6 +19,7 @@ DEFAULT_MAX_MESSAGES = 200
MAX_PER_PAGE = 200 MAX_PER_PAGE = 200
DEFAULT_OUTPUT_FILE = str(SCRIPT_DIR / "generated" / "mattermost_context.jsonl") DEFAULT_OUTPUT_FILE = str(SCRIPT_DIR / "generated" / "mattermost_context.jsonl")
REQUEST_TIMEOUT = 15 REQUEST_TIMEOUT = 15
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
LOGGER = logging.getLogger("mattermost_context") LOGGER = logging.getLogger("mattermost_context")
@@ -29,6 +32,8 @@ CHANNEL_SPECS: List[Dict[str, str]] = []
WINDOW_HOURS = DEFAULT_WINDOW_HOURS WINDOW_HOURS = DEFAULT_WINDOW_HOURS
MAX_MESSAGES = DEFAULT_MAX_MESSAGES MAX_MESSAGES = DEFAULT_MAX_MESSAGES
CUTOFF_TIMESTAMP_MS = 0 CUTOFF_TIMESTAMP_MS = 0
RANGE_START_TIMESTAMP_MS = 0
RANGE_END_TIMESTAMP_MS = 0
OUTPUT_FILE = DEFAULT_OUTPUT_FILE OUTPUT_FILE = DEFAULT_OUTPUT_FILE
REQUEST_HEADERS: Dict[str, str] = {} REQUEST_HEADERS: Dict[str, str] = {}
SSL_CONTEXT: ssl.SSLContext | None = None SSL_CONTEXT: ssl.SSLContext | None = None
@@ -40,6 +45,44 @@ class MattermostAPIError(RuntimeError):
pass pass
def parse_args() -> Namespace:
parser = ArgumentParser(description="Extract Mattermost messages as JSONL context.")
parser.add_argument(
"--previous-workday",
action="store_true",
help="Fetch the latest prior calendar day with Mattermost activity instead of a fixed recent window.",
)
parser.add_argument(
"--today",
default=date.today().isoformat(),
help="Reference date in YYYY-MM-DD format. Defaults to today.",
)
parser.add_argument(
"--max-lookback-days",
type=int,
default=int(os.getenv("MATTERMOST_MAX_LOOKBACK_DAYS", "7")),
help="Maximum days to search backward with --previous-workday.",
)
parser.add_argument(
"--window-hours",
type=int,
default=0,
help="Override MESSAGE_WINDOW_HOURS for normal recent-window mode.",
)
parser.add_argument(
"--output-file",
default="",
help="Override MATTERMOST_OUTPUT_FILE.",
)
return parser.parse_args()
def parse_iso_date(raw_value: str) -> date:
if not DATE_RE.match(raw_value):
raise ValueError(f"Invalid date '{raw_value}'. Use YYYY-MM-DD.")
return datetime.strptime(raw_value, "%Y-%m-%d").date()
def parse_bool_env(name: str, default: bool = False) -> bool: def parse_bool_env(name: str, default: bool = False) -> bool:
raw_value = os.getenv(name) raw_value = os.getenv(name)
if raw_value is None: if raw_value is None:
@@ -133,18 +176,18 @@ def build_ssl_context() -> ssl.SSLContext:
return ssl.create_default_context() return ssl.create_default_context()
def configure() -> None: def configure(args: Namespace) -> None:
global MATTERMOST_URL, CHANNEL_SPECS, WINDOW_HOURS, MAX_MESSAGES, CUTOFF_TIMESTAMP_MS, OUTPUT_FILE global MATTERMOST_URL, CHANNEL_SPECS, WINDOW_HOURS, MAX_MESSAGES, CUTOFF_TIMESTAMP_MS, OUTPUT_FILE
global REQUEST_HEADERS, SSL_CONTEXT global RANGE_START_TIMESTAMP_MS, RANGE_END_TIMESTAMP_MS, REQUEST_HEADERS, SSL_CONTEXT
global MATTERMOST_TEAM_NAME, MATTERMOST_TEAM_ID global MATTERMOST_TEAM_NAME, MATTERMOST_TEAM_ID
load_dotenv_file() load_dotenv_file()
MATTERMOST_URL = require_env("MATTERMOST_URL").rstrip("/") MATTERMOST_URL = require_env("MATTERMOST_URL").rstrip("/")
token = require_env("MATTERMOST_TOKEN") token = require_env("MATTERMOST_TOKEN")
CHANNEL_SPECS = parse_channel_specs() CHANNEL_SPECS = parse_channel_specs()
WINDOW_HOURS = int(os.getenv("MESSAGE_WINDOW_HOURS", str(DEFAULT_WINDOW_HOURS))) WINDOW_HOURS = args.window_hours or int(os.getenv("MESSAGE_WINDOW_HOURS", str(DEFAULT_WINDOW_HOURS)))
MAX_MESSAGES = int(os.getenv("MAX_MESSAGES", str(DEFAULT_MAX_MESSAGES))) 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 OUTPUT_FILE = args.output_file or os.getenv("MATTERMOST_OUTPUT_FILE", DEFAULT_OUTPUT_FILE).strip() or DEFAULT_OUTPUT_FILE
MATTERMOST_TEAM_NAME = os.getenv("MATTERMOST_TEAM_NAME", "").strip() MATTERMOST_TEAM_NAME = os.getenv("MATTERMOST_TEAM_NAME", "").strip()
MATTERMOST_TEAM_ID = os.getenv("MATTERMOST_TEAM_ID", "").strip() MATTERMOST_TEAM_ID = os.getenv("MATTERMOST_TEAM_ID", "").strip()
@@ -155,6 +198,8 @@ def configure() -> None:
cutoff = datetime.now().astimezone() - timedelta(hours=WINDOW_HOURS) cutoff = datetime.now().astimezone() - timedelta(hours=WINDOW_HOURS)
CUTOFF_TIMESTAMP_MS = int(cutoff.timestamp() * 1000) CUTOFF_TIMESTAMP_MS = int(cutoff.timestamp() * 1000)
RANGE_START_TIMESTAMP_MS = 0
RANGE_END_TIMESTAMP_MS = 0
REQUEST_HEADERS = { REQUEST_HEADERS = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
@@ -194,6 +239,8 @@ def get_channel_posts(channel_id: str) -> List[Dict[str, Any]]:
collected: List[Dict[str, Any]] = [] collected: List[Dict[str, Any]] = []
page = 0 page = 0
per_page = min(MAX_PER_PAGE, MAX_MESSAGES) per_page = min(MAX_PER_PAGE, MAX_MESSAGES)
start_timestamp_ms = RANGE_START_TIMESTAMP_MS or CUTOFF_TIMESTAMP_MS
end_timestamp_ms = RANGE_END_TIMESTAMP_MS
while len(collected) < MAX_MESSAGES: while len(collected) < MAX_MESSAGES:
payload = api_get_json( payload = api_get_json(
@@ -213,9 +260,11 @@ def get_channel_posts(channel_id: str) -> List[Dict[str, Any]]:
continue continue
created_at = int(post.get("create_at", 0)) created_at = int(post.get("create_at", 0))
if created_at < CUTOFF_TIMESTAMP_MS: if created_at < start_timestamp_ms:
reached_cutoff = True reached_cutoff = True
continue continue
if end_timestamp_ms and created_at >= end_timestamp_ms:
continue
collected.append(post) collected.append(post)
if len(collected) >= MAX_MESSAGES: if len(collected) >= MAX_MESSAGES:
@@ -375,10 +424,10 @@ def is_system_message(post: Dict[str, Any]) -> bool:
return post_type.startswith("system_") return post_type.startswith("system_")
def extract_messages() -> List[Dict[str, Any]]: def extract_messages(resolved_channels: List[Dict[str, Any]] | None = None) -> List[Dict[str, Any]]:
all_messages: List[Dict[str, Any]] = [] all_messages: List[Dict[str, Any]] = []
for channel in resolve_channels(): for channel in resolved_channels or resolve_channels():
channel_id = channel.get("id", "") channel_id = channel.get("id", "")
channel_name = channel.get("name", "") or channel_id channel_name = channel.get("name", "") or channel_id
channel_display_name = channel.get("display_name", "") or channel_name channel_display_name = channel.get("display_name", "") or channel_name
@@ -418,6 +467,38 @@ def extract_messages() -> List[Dict[str, Any]]:
return all_messages return all_messages
def day_range_ms(day: date) -> tuple[int, int]:
start = datetime.combine(day, time.min).astimezone()
end = start + timedelta(days=1)
return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
def set_fetch_range(start_ms: int, end_ms: int) -> None:
global RANGE_START_TIMESTAMP_MS, RANGE_END_TIMESTAMP_MS
RANGE_START_TIMESTAMP_MS = start_ms
RANGE_END_TIMESTAMP_MS = end_ms
def extract_previous_workday_messages(args: Namespace) -> tuple[List[Dict[str, Any]], date | None, int]:
today = parse_iso_date(args.today)
resolved_channels = resolve_channels()
max_lookback_days = args.max_lookback_days
if max_lookback_days <= 0:
raise ValueError("--max-lookback-days must be greater than 0.")
for skipped_days in range(max_lookback_days):
candidate_day = today - timedelta(days=skipped_days + 1)
start_ms, end_ms = day_range_ms(candidate_day)
set_fetch_range(start_ms, end_ms)
messages = extract_messages(resolved_channels)
if messages:
return messages, candidate_day, skipped_days
LOGGER.info("No messages found for %s; expanding lookback.", candidate_day.isoformat())
return [], None, max_lookback_days
def format_messages(messages: List[Dict[str, Any]]) -> str: def format_messages(messages: List[Dict[str, Any]]) -> str:
lines: List[str] = [] lines: List[str] = []
@@ -473,8 +554,20 @@ def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
try: try:
configure() args = parse_args()
messages = extract_messages() configure(args)
if args.previous_workday:
messages, selected_day, skipped_days = extract_previous_workday_messages(args)
if selected_day:
LOGGER.info(
"Selected previous workday %s after skipping %s inactive calendar day(s).",
selected_day.isoformat(),
skipped_days,
)
else:
LOGGER.info("No previous workday messages found within %s day(s).", skipped_days)
else:
messages = extract_messages()
output = format_messages(messages) output = format_messages(messages)
print(output) print(output)
save_to_file(output) save_to_file(output)

View File

@@ -12,4 +12,4 @@ else
PYTHON_BIN="python3" PYTHON_BIN="python3"
fi fi
exec "$PYTHON_BIN" "$SCRIPT_DIR/mattermost_context.py" exec "$PYTHON_BIN" "$SCRIPT_DIR/mattermost_context.py" "$@"