From 14d04d1342be7efe7c2fa9a4798eba7e0b2af322 Mon Sep 17 00:00:00 2001 From: Nexus Project Date: Fri, 26 Jun 2026 12:56:46 +0530 Subject: [PATCH 1/3] feat(dex-v2): product-grade chat orchestration + proactive priority feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the monkey-patched Discord chat path with a transport-independent, product-grade orchestration foundation, and add the proactive Priority Feed. Channel harness (nexus/communication/channels.py) - ChannelRole: semantic, platform-independent roles (CHAT, NOTIFICATION, PRIORITY_FEED, BRIEFING, APPROVAL, SYSTEM). - ChannelMessage: transport-independent inbound message; every adapter normalizes its native event into this. Orchestration never sees the platform. - ChannelRouter: declarative role <-> channel binding + per-role policy (respond-without-mention, post-only, mention-owner). Chat pipeline (nexus/communication/chat/) - Conversation -> Planner -> ChatAction -> Validator -> Executor. - Typed ChatAction contract; governance flags (requires_owner/requires_approval) are stamped server-side from a trusted table, never inferred from the LLM. - Validator enforces owner/schema/approval gates; Executor performs side effects via injected services and emits a SYSTEM status card. - Per-conversation memory passed to the planner (fixes incoherent follow-ups). Thin Discord adapter (nexus/communication/discord/bot.py) - normalize -> ChatService.handle -> render. No LLM/email/DB logic in the adapter. Proactive Priority Feed (nexus/intelligence/feed.py) - After each research run, high-importance new findings are routed via ChannelRole.PRIORITY_FEED onto the transactional outbox as an owner-tagged, influencer-style digest. Research stays Discord-agnostic. - Outbox discord delivery generalized to honor payload['channel_key'] (defaults to 'summaries' — backward compatible) so one outbox serves every channel role. - Composed into the existing 2-hourly research job; config toggle + threshold. Tests: +17 E2E (channel harness, chat orchestration, priority feed). Gate: 236 passed, ruff clean, mypy clean (69 files). --- nexus/api.py | 22 +++- nexus/communication/channels.py | 123 ++++++++++++++++++++ nexus/communication/chat/__init__.py | 26 +++++ nexus/communication/chat/contracts.py | 53 +++++++++ nexus/communication/chat/executor.py | 96 ++++++++++++++++ nexus/communication/chat/planner.py | 124 +++++++++++++++++++++ nexus/communication/chat/service.py | 115 +++++++++++++++++++ nexus/communication/chat/validator.py | 56 ++++++++++ nexus/communication/discord/bot.py | 98 ++++++++++++++++ nexus/config.py | 20 ++++ nexus/gateway/communication_outbox.py | 21 +++- nexus/intelligence/feed.py | 135 ++++++++++++++++++++++ nexus/intelligence/openrouter.py | 16 ++- nexus/scheduling/jobs.py | 14 ++- tests/e2e/test_channel_harness.py | 68 ++++++++++++ tests/e2e/test_chat_orchestration.py | 133 ++++++++++++++++++++++ tests/e2e/test_priority_feed.py | 154 ++++++++++++++++++++++++++ 17 files changed, 1261 insertions(+), 13 deletions(-) create mode 100644 nexus/communication/channels.py create mode 100644 nexus/communication/chat/__init__.py create mode 100644 nexus/communication/chat/contracts.py create mode 100644 nexus/communication/chat/executor.py create mode 100644 nexus/communication/chat/planner.py create mode 100644 nexus/communication/chat/service.py create mode 100644 nexus/communication/chat/validator.py create mode 100644 nexus/intelligence/feed.py create mode 100644 tests/e2e/test_channel_harness.py create mode 100644 tests/e2e/test_chat_orchestration.py create mode 100644 tests/e2e/test_priority_feed.py diff --git a/nexus/api.py b/nexus/api.py index 32e8897..8d245eb 100644 --- a/nexus/api.py +++ b/nexus/api.py @@ -17,6 +17,7 @@ from fastapi import APIRouter, FastAPI, Request, Response, status from nexus import __version__ +from nexus.communication.chat import ChatService from nexus.communication.discord import DiscordService, NexusBot, set_bot from nexus.config import NexusSettings, get_settings from nexus.core.exceptions import ConfigurationError @@ -138,13 +139,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: event_gateway = EventGateway() openrouter_client = OpenRouterClient(settings) - # Boot Discord bot adapter - discord_bot = NexusBot(settings, _state.session_factory, event_gateway) + # Boot Discord bot adapter. The adapter is thin: it delegates conversation to ChatService + # (Planner → Validator → Executor) and routes via the channel harness. + from nexus.communication.email.service import EmailService + + email_service = EmailService(settings) + chat_service = ChatService.build( + llm_client=openrouter_client, + email_service=email_service, + owner_email=settings.email.to_address, + ) + discord_bot = NexusBot( + settings, + _state.session_factory, + event_gateway, + llm_client=openrouter_client, + chat_service=chat_service, + ) _state.discord_bot = discord_bot set_bot(discord_bot) discord_service = DiscordService(discord_bot) - from nexus.communication.email.service import EmailService - email_service = EmailService(settings) # Boot workflow orchestrator orchestrator = WorkflowOrchestrator( diff --git a/nexus/communication/channels.py b/nexus/communication/channels.py new file mode 100644 index 0000000..4d77ac7 --- /dev/null +++ b/nexus/communication/channels.py @@ -0,0 +1,123 @@ +"""Channel Harness — transport-independent message model and declarative routing. + +This module is the meta-level routing layer for the control plane. It defines: + +* :class:`ChannelRole` — *semantic* roles (chat, notification, priority feed, …) that are + independent of any platform (Discord/Slack/CLI/REST). +* :class:`ChannelMessage` — a transport-independent inbound message. Every adapter converts its + native event into a ``ChannelMessage`` so the orchestration layer never knows the platform. +* :class:`ChannelRouter` — declarative mapping ``ChannelRole`` ⇄ a concrete channel, plus the + behavioural policy of each role (reply-without-mention, post-only, mention-owner). + +It contains **no platform API calls and no business logic** — pure, deterministic mapping that is +unit/E2E testable in isolation and reused by adapters, schedulers, and the proactive feed alike. + +Example (research never mentions Discord):: + + research → importance=HIGH → ChannelRouter(role=PRIORITY_FEED) → adapter → Discord +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, Field + +from nexus.config import DiscordChannels + + +class ChannelRole(enum.StrEnum): + """Semantic, transport-independent role of a channel.""" + + CHAT = "chat" # free-form operator conversation + NOTIFICATION = "notification" # reminders / TODOs / nudges (mention owner) + PRIORITY_FEED = "priority_feed" # high-importance briefs (mention owner) + BRIEFING = "briefing" # scheduled digests / summaries + APPROVAL = "approval" # approval cards + SYSTEM = "system" # Dex status / action cards / system logs + + +class ChannelMessage(BaseModel): + """Transport-independent inbound message. Adapters normalize native events into this.""" + + role: ChannelRole = ChannelRole.CHAT + author: str # platform-agnostic author id (e.g. discord user id as str) + channel_id: str + conversation_id: str # stable key for conversation memory + message: str + metadata: dict[str, Any] = Field(default_factory=dict) # e.g. {"is_owner": bool, "is_dm": bool} + + +@dataclass(frozen=True) +class ChannelPolicy: + """Behavioural policy for a channel role.""" + + role: ChannelRole + respond_without_mention: bool = False # reply to plain (un-mentioned) messages here + post_only: bool = False # Dex posts here but does not converse + mention_owner: bool = False # prepend an owner mention to posts + + +_POLICIES: dict[ChannelRole, ChannelPolicy] = { + ChannelRole.CHAT: ChannelPolicy(ChannelRole.CHAT, respond_without_mention=True), + ChannelRole.NOTIFICATION: ChannelPolicy(ChannelRole.NOTIFICATION, post_only=True, mention_owner=True), + ChannelRole.PRIORITY_FEED: ChannelPolicy(ChannelRole.PRIORITY_FEED, post_only=True, mention_owner=True), + ChannelRole.BRIEFING: ChannelPolicy(ChannelRole.BRIEFING, post_only=True), + ChannelRole.APPROVAL: ChannelPolicy(ChannelRole.APPROVAL, post_only=True), + ChannelRole.SYSTEM: ChannelPolicy(ChannelRole.SYSTEM, post_only=True), +} + +# Declarative binding: semantic role -> DiscordChannels attribute that resolves the concrete name. +# A future Slack/REST adapter would supply its own binding; the roles stay identical. +_DEFAULT_DISCORD_BINDING: dict[ChannelRole, str] = { + ChannelRole.CHAT: "general", + ChannelRole.NOTIFICATION: "reminders", + ChannelRole.PRIORITY_FEED: "priority_feed", + ChannelRole.BRIEFING: "summaries", + ChannelRole.APPROVAL: "approvals", + ChannelRole.SYSTEM: "console", +} + + +class ChannelRouter: + """Resolves semantic roles ⇄ concrete channels and exposes per-role policy.""" + + def __init__( + self, + channels: DiscordChannels, + binding: dict[ChannelRole, str] | None = None, + ) -> None: + self._channels = channels + self._binding = binding or _DEFAULT_DISCORD_BINDING + # Reverse map concrete-name -> role for inbound routing (lower-cased for tolerance). + self._name_to_role: dict[str, ChannelRole] = {} + for role, key in self._binding.items(): + name = getattr(channels, key, None) + if name: + self._name_to_role[str(name).lower()] = role + + def policy(self, role: ChannelRole) -> ChannelPolicy: + """Return the behavioural policy for a role.""" + return _POLICIES[role] + + def channel_key(self, role: ChannelRole) -> str | None: + """Return the DiscordChannels attribute name bound to this role.""" + return self._binding.get(role) + + def channel_name(self, role: ChannelRole) -> str | None: + """Return the concrete channel name bound to a role.""" + key = self._binding.get(role) + return getattr(self._channels, key, None) if key else None + + def role_for_channel_name(self, name: str | None) -> ChannelRole | None: + """Map an inbound concrete channel name to its semantic role (None if unmapped).""" + if not name: + return None + return self._name_to_role.get(name.lower()) + + def respond_without_mention(self, channel_name: str | None) -> bool: + """True if Dex should reply to plain (un-mentioned) messages in this channel.""" + role = self.role_for_channel_name(channel_name) + return role is not None and self.policy(role).respond_without_mention diff --git a/nexus/communication/chat/__init__.py b/nexus/communication/chat/__init__.py new file mode 100644 index 0000000..3fc9e68 --- /dev/null +++ b/nexus/communication/chat/__init__.py @@ -0,0 +1,26 @@ +"""Chat orchestration package: Conversation → Planner → Validator → Executor.""" + +from __future__ import annotations + +from nexus.communication.chat.contracts import ( + ChatAction, + ChatActionType, + ChatResponse, + OutboundPost, +) +from nexus.communication.chat.executor import Executor +from nexus.communication.chat.planner import Planner +from nexus.communication.chat.service import ChatService +from nexus.communication.chat.validator import ValidationResult, Validator + +__all__ = [ + "ChatAction", + "ChatActionType", + "ChatResponse", + "ChatService", + "Executor", + "OutboundPost", + "Planner", + "ValidationResult", + "Validator", +] diff --git a/nexus/communication/chat/contracts.py b/nexus/communication/chat/contracts.py new file mode 100644 index 0000000..4094f1f --- /dev/null +++ b/nexus/communication/chat/contracts.py @@ -0,0 +1,53 @@ +"""Chat pipeline contracts — the typed boundary between Planner, Validator, and Executor. + +Governance is **encoded into the contract** (``requires_owner`` / ``requires_approval``) rather than +inferred downstream, so the Validator enforces policy from data, not from heuristics. These flags are +stamped server-side by the Planner from a trusted policy table — never taken from the LLM. +""" + +from __future__ import annotations + +import enum +from typing import Any + +from pydantic import BaseModel, Field + +from nexus.communication.channels import ChannelRole + + +class ChatActionType(enum.StrEnum): + """The set of actions the planner may select.""" + + REPLY = "reply" + SEND_EMAIL = "send_email" + CREATE_TASK = "create_task" + RUN_RESEARCH = "run_research" + SHOW_STATUS = "show_status" + APPROVAL_REQUEST = "approval_request" + + +class ChatAction(BaseModel): + """A planned action with its parameters and governance requirements.""" + + type: ChatActionType + payload: dict[str, Any] = Field(default_factory=dict) + confidence: float = 1.0 + requires_owner: bool = False + requires_approval: bool = False + + +class OutboundPost(BaseModel): + """A message the orchestration layer wants routed to a (non-origin) semantic channel.""" + + role: ChannelRole + content: str | None = None + card: dict[str, Any] | None = None # structured status-card payload (adapter renders it) + + +class ChatResponse(BaseModel): + """Structured result the adapter renders. Contains no platform types.""" + + reply: str | None = None # text back to the originating conversation + posts: list[OutboundPost] = Field(default_factory=list) # routed elsewhere (e.g. SYSTEM card) + action_type: ChatActionType = ChatActionType.REPLY + executed: bool = False diff --git a/nexus/communication/chat/executor.py b/nexus/communication/chat/executor.py new file mode 100644 index 0000000..44ee672 --- /dev/null +++ b/nexus/communication/chat/executor.py @@ -0,0 +1,96 @@ +"""Executor — performs a validated :class:`ChatAction` via injected services. + +Responsibility boundary: the executor decides *how* to carry out an action. It calls domain services +(EmailService, …) that are injected, emits a SYSTEM status card for side-effecting actions, and +returns a transport-neutral :class:`ChatResponse`. It performs no governance decisions (the Validator +already approved) and no LLM calls. +""" + +from __future__ import annotations + +from typing import Any + +import structlog + +from nexus.communication.channels import ChannelRole +from nexus.communication.chat.contracts import ( + ChatAction, + ChatActionType, + ChatResponse, + OutboundPost, +) + +logger = structlog.get_logger("nexus.communication.chat.executor") + + +class Executor: + """Executes validated actions against injected domain services.""" + + def __init__(self, email_service: Any = None, owner_email: str = "") -> None: + self.email_service = email_service + self.owner_email = owner_email + + async def execute(self, action: ChatAction) -> ChatResponse: + """Dispatch the action; return a transport-neutral response.""" + if action.type is ChatActionType.REPLY: + return ChatResponse( + reply=str(action.payload.get("message") or "(no response)"), + action_type=action.type, + executed=True, + ) + if action.type is ChatActionType.SEND_EMAIL: + return await self._send_email(action) + # Recognized but not yet wired to a domain service — honest, non-crashing response. + return ChatResponse( + reply=f"That action (`{action.type.value}`) is recognized but not wired up yet.", + action_type=action.type, + executed=False, + ) + + async def _send_email(self, action: ChatAction) -> ChatResponse: + subject = str(action.payload.get("subject") or "Message from Nexus").strip() + body = str(action.payload.get("body") or "").strip() + recipient = self.owner_email or "operator" + + if self.email_service is None: + return ChatResponse( + reply="⚠️ Email is not configured.", + action_type=ChatActionType.SEND_EMAIL, + executed=False, + posts=[_card(subject, "failed")], + ) + try: + html = ( + "
" + f"{body}
" + ) + await self.email_service.send_briefing_email(subject, body, html) + except Exception as e: # surface failure; never crash + logger.error("executor_send_email_failed", error=str(e)) + return ChatResponse( + reply=f"❌ Email failed: {e!s}", + action_type=ChatActionType.SEND_EMAIL, + executed=False, + posts=[_card(subject, "failed")], + ) + logger.info("executor_email_sent", recipient=recipient, subject=subject) + return ChatResponse( + reply=f"📧 Sent to **{recipient}** — *{subject}*", + action_type=ChatActionType.SEND_EMAIL, + executed=True, + posts=[_card(subject, "sent")], + ) + + +def _card(subject: str, verification: str) -> OutboundPost: + """Build a SYSTEM-channel status card for an email action.""" + return OutboundPost( + role=ChannelRole.SYSTEM, + card={ + "title": "Dex • Email Action", + "risk": "LOW", + "plan": f"Email operator: {subject}", + "tools": "send_email", + "verification": verification, + }, + ) diff --git a/nexus/communication/chat/planner.py b/nexus/communication/chat/planner.py new file mode 100644 index 0000000..05bf7bf --- /dev/null +++ b/nexus/communication/chat/planner.py @@ -0,0 +1,124 @@ +"""Planner — turns a conversation turn into a typed :class:`ChatAction`. + +Responsibility boundary: the planner decides *what* to do (action type + parameters) using the LLM, +then stamps governance requirements from a **trusted server-side policy table**. It never executes +anything and never trusts the model to set ``requires_owner`` / ``requires_approval``. +""" + +from __future__ import annotations + +import json +from typing import Any + +import structlog + +from nexus.communication.chat.contracts import ChatAction, ChatActionType + +logger = structlog.get_logger("nexus.communication.chat.planner") + +# Trusted governance policy per action type: (requires_owner, requires_approval). +# This is the ONLY place these flags are set — the LLM cannot influence them. +_ACTION_POLICY: dict[ChatActionType, tuple[bool, bool]] = { + ChatActionType.REPLY: (False, False), + ChatActionType.SEND_EMAIL: (True, False), # owner-only, low-risk + ChatActionType.CREATE_TASK: (True, False), + ChatActionType.RUN_RESEARCH: (True, False), + ChatActionType.SHOW_STATUS: (False, False), + ChatActionType.APPROVAL_REQUEST: (True, True), +} + +_SYSTEM_PROMPT = ( + "You are Nexus (call-sign 'Dex'), an AI Orchestration Control Plane assistant for your operator. " + "Use the prior turns for context. Decide the single best action and reply with ONE JSON object " + "and nothing else, using this schema:\n" + ' {"type": "reply", "message": ""}\n' + ' {"type": "send_email", "subject": "", "body": ""}\n' + ' {"type": "create_task", "title": "", "description": "<desc>", "priority": 2}\n' + ' {"type": "run_research", "topic": "<topic>"}\n' + ' {"type": "show_status"}\n' + "Choose send_email ONLY when explicitly asked to email/mail/send something (it goes to the " + "operator). Choose create_task / run_research only when explicitly asked to. Otherwise use " + "reply. Optionally include a numeric \"confidence\" between 0 and 1." +) + +# Map JSON "type" strings to enum, tolerant of synonyms. +_TYPE_ALIASES: dict[str, ChatActionType] = { + "reply": ChatActionType.REPLY, + "send_email": ChatActionType.SEND_EMAIL, + "email": ChatActionType.SEND_EMAIL, + "create_task": ChatActionType.CREATE_TASK, + "task": ChatActionType.CREATE_TASK, + "run_research": ChatActionType.RUN_RESEARCH, + "research": ChatActionType.RUN_RESEARCH, + "show_status": ChatActionType.SHOW_STATUS, + "status": ChatActionType.SHOW_STATUS, + "approval_request": ChatActionType.APPROVAL_REQUEST, +} + + +class Planner: + """Plans a :class:`ChatAction` from a message + history using the LLM gateway.""" + + def __init__(self, llm_client: Any) -> None: + self.llm_client = llm_client + + async def plan(self, text: str, history: list[dict[str, str]] | None = None) -> ChatAction: + """Produce a governance-stamped ChatAction; degrade to a plain REPLY on any failure.""" + if self.llm_client is None: + return self._reply("⚠️ Chat is unavailable: no LLM gateway is configured.", confidence=1.0) + try: + raw = await self.llm_client.complete( + text, system_prompt=_SYSTEM_PROMPT, history=history or [] + ) + except Exception as e: # propagate as a reply; never crash the pipeline + logger.error("planner_llm_failed", error=str(e)) + return self._reply(f"❌ Chat error: {e!s}", confidence=1.0) + + data = self._extract_json(raw) + if data is None: + # Not structured — treat the whole text as a conversational reply. + return self._reply((raw or "").strip() or "(no response)") + + action_type = _TYPE_ALIASES.get(str(data.get("type", "reply")).lower(), ChatActionType.REPLY) + payload = {k: v for k, v in data.items() if k not in ("type", "confidence")} + if action_type is ChatActionType.REPLY and not payload.get("message"): + payload["message"] = (raw or "").strip() or "(no response)" + confidence = self._as_float(data.get("confidence"), default=0.9) + requires_owner, requires_approval = _ACTION_POLICY[action_type] + return ChatAction( + type=action_type, + payload=payload, + confidence=confidence, + requires_owner=requires_owner, + requires_approval=requires_approval, + ) + + @staticmethod + def _reply(message: str, confidence: float = 0.9) -> ChatAction: + owner, approval = _ACTION_POLICY[ChatActionType.REPLY] + return ChatAction( + type=ChatActionType.REPLY, + payload={"message": message}, + confidence=confidence, + requires_owner=owner, + requires_approval=approval, + ) + + @staticmethod + def _extract_json(raw: str) -> dict[str, Any] | None: + text = (raw or "").strip() + start, end = text.find("{"), text.rfind("}") + if start == -1 or end == -1 or end <= start: + return None + try: + data = json.loads(text[start : end + 1]) + except (json.JSONDecodeError, ValueError): + return None + return data if isinstance(data, dict) and "type" in data else None + + @staticmethod + def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/nexus/communication/chat/service.py b/nexus/communication/chat/service.py new file mode 100644 index 0000000..7c83fd3 --- /dev/null +++ b/nexus/communication/chat/service.py @@ -0,0 +1,115 @@ +"""ChatService — composes the Conversation → Planner → Validator → Executor pipeline. + +This is the single entry point the (thin) adapters call. It owns conversation memory and wires the +three responsibilities together; it holds no platform types and performs no I/O beyond delegating to +the injected planner/validator/executor. + + ChannelMessage → [memory] → Planner → ChatAction → Validator → Executor → ChatResponse +""" + +from __future__ import annotations + +from typing import Any + +import structlog + +from nexus.communication.channels import ChannelMessage +from nexus.communication.chat.contracts import ChatActionType, ChatResponse +from nexus.communication.chat.executor import Executor +from nexus.communication.chat.planner import Planner +from nexus.communication.chat.validator import Validator + +logger = structlog.get_logger("nexus.communication.chat.service") + +MAX_CHAT_HISTORY = 12 # user+assistant messages retained per conversation + + +class ChatService: + """Orchestrates the chat pipeline and conversation memory.""" + + def __init__( + self, + planner: Planner, + validator: Validator, + executor: Executor, + ) -> None: + self.planner = planner + self.validator = validator + self.executor = executor + self._history: dict[str, list[dict[str, str]]] = {} + + @classmethod + def build( + cls, + llm_client: Any, + email_service: Any = None, + owner_email: str = "", + ) -> ChatService: + """Convenience constructor wiring the default pipeline from services.""" + return cls( + planner=Planner(llm_client), + validator=Validator(), + executor=Executor(email_service=email_service, owner_email=owner_email), + ) + + def history_for(self, conversation_id: str) -> list[dict[str, str]]: + """Expose a conversation's rolling history (for tests/inspection).""" + return self._history.setdefault(conversation_id, []) + + async def handle(self, message: ChannelMessage) -> ChatResponse: + """Run the full pipeline for one inbound message, updating conversation memory.""" + history = self._history.setdefault(message.conversation_id, []) + + action = await self.planner.plan(message.message, history[-MAX_CHAT_HISTORY:]) + verdict = self.validator.validate(action, message) + + if not verdict.ok: + response = ChatResponse(reply=verdict.reason, action_type=action.type, executed=False) + elif verdict.needs_approval: + # Governance: action requires human approval before execution (not auto-run here). + response = ChatResponse( + reply="That action needs approval — I've flagged it for the owner.", + action_type=action.type, + executed=False, + ) + logger.info("chat_action_requires_approval", action=action.type.value) + else: + response = await self.executor.execute(action) + + # Update rolling memory with a compact assistant note. + note = response.reply or f"({action.type.value})" + history.append({"role": "user", "content": message.message}) + history.append({"role": "assistant", "content": note[:1500]}) + del history[:-MAX_CHAT_HISTORY] + + logger.info( + "chat_handled", + action=action.type.value, + executed=response.executed, + conversation=message.conversation_id, + ) + return response + + # Convenience for callers that only have raw text (e.g. unit harnesses). + async def handle_text( + self, *, conversation_id: str, text: str, author: str = "unknown", is_owner: bool = False + ) -> ChatResponse: + """Build a CHAT ChannelMessage from raw fields and run the pipeline.""" + msg = ChannelMessage( + author=author, + channel_id=conversation_id, + conversation_id=conversation_id, + message=text, + metadata={"is_owner": is_owner}, + ) + return await self.handle(msg) + + +__all__ = [ + "ChatActionType", + "ChatResponse", + "ChatService", + "Executor", + "Planner", + "Validator", +] diff --git a/nexus/communication/chat/validator.py b/nexus/communication/chat/validator.py new file mode 100644 index 0000000..b6a7638 --- /dev/null +++ b/nexus/communication/chat/validator.py @@ -0,0 +1,56 @@ +"""Validator — enforces governance and schema on a planned :class:`ChatAction`. + +Responsibility boundary: the validator decides *whether* an action may run. It reads the governance +requirements already encoded in the action (``requires_owner`` / ``requires_approval``) plus the +caller's context, and checks the payload has the fields the executor needs. It performs no LLM calls +and no side effects. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from nexus.communication.channels import ChannelMessage +from nexus.communication.chat.contracts import ChatAction, ChatActionType + +# Required payload keys per action type (schema check). +_REQUIRED_FIELDS: dict[ChatActionType, tuple[str, ...]] = { + ChatActionType.REPLY: ("message",), + ChatActionType.SEND_EMAIL: ("body",), + ChatActionType.CREATE_TASK: ("title",), + ChatActionType.RUN_RESEARCH: ("topic",), + ChatActionType.SHOW_STATUS: (), + ChatActionType.APPROVAL_REQUEST: (), +} + + +@dataclass(frozen=True) +class ValidationResult: + """Outcome of validation. ``reason`` is operator-facing when ``ok`` is False.""" + + ok: bool + reason: str = "" + needs_approval: bool = False + + +class Validator: + """Validates governance (owner/approval) and schema for a ChatAction.""" + + def validate(self, action: ChatAction, message: ChannelMessage) -> ValidationResult: + """Return whether the action may execute given the caller context.""" + # 1. Owner gate (encoded in the contract). + if action.requires_owner and not bool(message.metadata.get("is_owner")): + return ValidationResult(False, "Sorry — only the owner can authorize that action.") + + # 2. Schema gate. + missing = [f for f in _REQUIRED_FIELDS.get(action.type, ()) if not action.payload.get(f)] + if missing: + return ValidationResult( + False, f"I couldn't action that — missing: {', '.join(missing)}." + ) + + # 3. Approval gate (encoded in the contract) — surfaced to the executor/caller. + if action.requires_approval: + return ValidationResult(True, needs_approval=True) + + return ValidationResult(True) diff --git a/nexus/communication/discord/bot.py b/nexus/communication/discord/bot.py index deac564..ec7891f 100644 --- a/nexus/communication/discord/bot.py +++ b/nexus/communication/discord/bot.py @@ -6,6 +6,7 @@ from __future__ import annotations +import contextlib import uuid from typing import Any @@ -16,6 +17,8 @@ from sqlalchemy.ext.asyncio import async_sessionmaker from nexus.approvals.service import ApprovalService +from nexus.communication.channels import ChannelMessage, ChannelRole, ChannelRouter +from nexus.communication.chat import ChatResponse, OutboundPost from nexus.config import get_settings from nexus.core.types import ApprovalStatus, TaskStatus from nexus.database import get_session @@ -26,6 +29,25 @@ logger = structlog.get_logger("nexus.communication.discord.bot") +def _build_card_embed(card: dict[str, Any]) -> discord.Embed: + """Render a transport-neutral status-card payload into a Discord embed.""" + verification = str(card.get("verification", "")) + color = ( + discord.Color.green() + if verification == "sent" + else discord.Color.red() + if verification == "failed" + else discord.Color.blurple() + ) + embed = discord.Embed(title=str(card.get("title", "Dex • Action")), color=color) + embed.add_field(name="Risk Level", value=str(card.get("risk", "LOW")), inline=True) + if verification: + embed.add_field(name="Verification", value=verification, inline=True) + embed.add_field(name="Execution Plan", value=str(card.get("plan", "None"))[:1000], inline=False) + embed.add_field(name="Tools Used", value=str(card.get("tools", "None")), inline=True) + return embed + + class ApprovalView(discord.ui.View): """Interactive view for manual approval gates containing Approve/Reject buttons.""" @@ -136,6 +158,8 @@ def __init__( settings: Any = None, session_factory: async_sessionmaker[Any] | None = None, event_gateway: Any = None, + llm_client: Any = None, + chat_service: Any = None, ) -> None: """Set intents and command configurations.""" intents = discord.Intents.default() @@ -146,6 +170,10 @@ def __init__( self.settings = settings or get_settings() self.session_factory = session_factory self.event_gateway = event_gateway + self.llm_client = llm_client + # Chat orchestration (core logic) + channel harness (routing). The adapter stays thin. + self.chat_service = chat_service + self.router = ChannelRouter(self.settings.discord.channels) self.guild_obj: discord.Guild | None = None async def setup_hook(self) -> None: @@ -183,6 +211,76 @@ async def on_ready(self) -> None: else: logger.warning("discord_bot_no_guild_id_configured") + async def on_message(self, message: discord.Message) -> None: + """Thin adapter: normalize → ChatService.handle() → render. No business logic here. + + Responds to a DM, an explicit @mention, or any message in a channel whose role allows + replies without a mention (the CHAT role). Slash commands are handled by the application + command tree, so we never call ``process_commands``. + """ + if message.author.bot or (self.user is not None and message.author.id == self.user.id): + return + + is_dm = message.guild is None + is_mention = self.user is not None and self.user in message.mentions + channel_name = getattr(message.channel, "name", None) + in_chat_channel = self.router.respond_without_mention(channel_name) + if not (is_dm or is_mention or in_chat_channel): + return + if self.chat_service is None: + return + + text = self._normalize_text(message) + if not text: + await message.channel.send( + "Hi — I'm **Nexus** (Dex). Chat with me in the chat channel (no @ needed), ask me " + "to `mail me ...`, or use `/task_create`, `/task_list`, `/task_status`." + ) + return + + channel_msg = ChannelMessage( + role=ChannelRole.CHAT, + author=str(message.author.id), + channel_id=str(message.channel.id), + conversation_id=str(message.channel.id), + message=text, + metadata={"is_owner": self._is_owner(message.author.id), "is_dm": is_dm}, + ) + async with message.channel.typing(): + response = await self.chat_service.handle(channel_msg) + await self._render(message, response) + + def _normalize_text(self, message: discord.Message) -> str: + """Strip the bot's mention tokens so only the operator's text remains.""" + content = message.content or "" + if self.user is not None: + for token in (f"<@{self.user.id}>", f"<@!{self.user.id}>"): + content = content.replace(token, "") + return content.strip() + + def _is_owner(self, user_id: int) -> bool: + return user_id in (self.settings.discord.owner_ids or []) + + async def _render(self, message: discord.Message, response: ChatResponse) -> None: + """Render a transport-neutral ChatResponse onto Discord (the only place with Discord I/O).""" + if response.reply: + for start in range(0, len(response.reply), 1900): + await message.channel.send(response.reply[start : start + 1900]) + for post in response.posts: + await self._render_post(post) + + async def _render_post(self, post: OutboundPost) -> None: + """Route an outbound post to the channel bound to its semantic role (best-effort).""" + key = self.router.channel_key(post.role) + channel = self.get_channel_by_config(key) if key else None + if channel is None: + return + with contextlib.suppress(Exception): + if post.card is not None: + await channel.send(embed=_build_card_embed(post.card)) + elif post.content: + await channel.send(post.content) + def get_channel_by_config(self, channel_key: str) -> discord.TextChannel | None: """Resolve a text channel by configured channel ID or name.""" if not self.guild_obj: diff --git a/nexus/config.py b/nexus/config.py index b1c8f4d..778faf0 100644 --- a/nexus/config.py +++ b/nexus/config.py @@ -30,6 +30,13 @@ class DiscordChannels(BaseModel): research: str = "nexus-research" summaries: str = "nexus-reports" alerts: str = "nexus-alerts" + # Dex v2 operator-facing channel taxonomy. + general: str = "general" # free-form chat — Dex replies without an @mention here + console: str = "console" # Dex status/action cards (Status / Task Initialized / Complete) + system_logs: str = Field("system-logs", alias="system-logs") # whole-repo system logs + timeline: str = "timeline" # time management (IST) + priority_feed: str = Field("priority-feed", alias="priority-feed") # @owner priority briefs + reminders: str = "reminders" # reminders & TODOs model_config = ConfigDict(populate_by_name=True) @@ -110,6 +117,13 @@ class SchedulingConfig(BaseModel): research_interval_hours: int = 2 research_feeds: dict[str, str] = Field(default_factory=dict) + # Proactive Priority Feed — after each research run, push newly-discovered high-importance + # findings to the PRIORITY_FEED channel (mentions the owner). Discord-agnostic; routes via + # the channel harness. No effect unless findings clear ``priority_feed_min_score``. + priority_feed_enabled: bool = True + priority_feed_min_score: int = 4 # importance_score >= this is "priority" (1-5 scale) + priority_feed_max_items: int = 5 # cap items per digest; remainder summarized as "+N more" + # J2 — Daily Briefing (cron, 08:00 in `timezone`) briefing_enabled: bool = True briefing_hour: int = 8 @@ -252,6 +266,12 @@ def from_yaml_and_env(cls, yaml_path: Path | None = None) -> NexusSettings: yaml_data["email"]["from_address"] = _from # Most SMTP providers (e.g. Gmail) authenticate with the sender address as username. yaml_data["email"].setdefault("username", _from) + # Recipient for operational digests/notifications: an explicit NOTIFY_EMAIL_TO wins; + # otherwise default to the sender address (operator self-delivery) so emails always have a + # valid RCPT TO. Without this, briefing email fails with SMTP 555 (empty recipient). + _to = os.getenv("NOTIFY_EMAIL_TO") or os.getenv("NOTIFY_EMAIL_FROM") + if _to and not yaml_data["email"].get("to_address"): + yaml_data["email"]["to_address"] = _to return cls(**yaml_data) diff --git a/nexus/gateway/communication_outbox.py b/nexus/gateway/communication_outbox.py index 7d9747f..5fd219e 100644 --- a/nexus/gateway/communication_outbox.py +++ b/nexus/gateway/communication_outbox.py @@ -23,8 +23,14 @@ logger = structlog.get_logger("nexus.gateway.communication_outbox") -async def _deliver_discord_chunks(discord_service: Any, content: str) -> None: - """Deliver content to Discord summaries channel with chunking controls.""" +async def _deliver_discord_chunks( + discord_service: Any, content: str, channel_key: str = "summaries" +) -> None: + """Deliver content to a mapped Discord channel with chunking controls. + + ``channel_key`` defaults to ``"summaries"`` for backward compatibility; the priority feed and + other roles pass their own key so the same transactional outbox serves every channel. + """ max_chunk = 1900 text = content chunks = [] @@ -42,7 +48,7 @@ async def _deliver_discord_chunks(discord_service: Any, content: str) -> None: chunks.append(text) for chunk in chunks: - await discord_service.post_message("summaries", content=chunk) + await discord_service.post_message(channel_key, content=chunk) async def _update_source_briefing_status( @@ -146,7 +152,8 @@ async def process_outbox_item( if not discord_service: raise RuntimeError("Discord service not configured") content = record.payload.get("content", "") - await _deliver_discord_chunks(discord_service, content) + channel_key = record.payload.get("channel_key", "summaries") + await _deliver_discord_chunks(discord_service, content, channel_key) elif record.channel == "email": if not email_service: raise RuntimeError("Email service not configured") @@ -261,7 +268,11 @@ async def flush_outbox_synchronously( try: if record.channel == "discord": if discord_service: - await _deliver_discord_chunks(discord_service, record.payload.get("content", "")) + await _deliver_discord_chunks( + discord_service, + record.payload.get("content", ""), + record.payload.get("channel_key", "summaries"), + ) record.status = "sent" elif record.channel == "email": if email_service: diff --git a/nexus/intelligence/feed.py b/nexus/intelligence/feed.py new file mode 100644 index 0000000..92e7934 --- /dev/null +++ b/nexus/intelligence/feed.py @@ -0,0 +1,135 @@ +"""Proactive Priority Feed dispatcher. + +After a research run, the highest-signal *new* findings are pushed to the operator between the +scheduled 08:00 briefings — an "influencer-style" drop that mentions the owner. + +This service is **transport-independent**: it resolves the destination through the channel harness +(:class:`~nexus.communication.channels.ChannelRouter` → :class:`ChannelRole.PRIORITY_FEED`) and +enqueues a row on the transactional communication outbox. It never imports Discord and never sends +anything itself — the outbox worker drains it, exactly like briefings. Research stays oblivious to +where its findings end up. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import structlog +from sqlalchemy import select + +from nexus.communication.channels import ChannelRole, ChannelRouter +from nexus.memory.models import ResearchFindingRecord, SystemOutboxRecord + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from nexus.config import NexusSettings + +logger = structlog.get_logger("nexus.intelligence.feed") + + +class PriorityFeedService: + """Selects high-importance findings and queues an owner-tagged digest to the priority feed.""" + + def __init__(self, db_session: AsyncSession, settings: NexusSettings) -> None: + self.session = db_session + self.settings = settings + self.router = ChannelRouter(settings.discord.channels) + + async def dispatch_new_findings(self, finding_ids: list[uuid.UUID]) -> uuid.UUID | None: + """Queue a priority-feed digest for the high-importance subset of ``finding_ids``. + + Returns the outbox correlation id if a digest was enqueued, else ``None`` (nothing cleared + the importance threshold, or the feed is disabled). + """ + sc = self.settings.scheduling + if not sc.priority_feed_enabled or not finding_ids: + return None + + findings = await self._load_priority_findings(finding_ids, sc.priority_feed_min_score) + if not findings: + logger.info("priority_feed_no_high_importance_findings", candidates=len(finding_ids)) + return None + + content = self._render_digest(findings, sc.priority_feed_max_items) + channel_key = self.router.channel_key(ChannelRole.PRIORITY_FEED) + correlation_id = uuid.uuid4() + + self.session.add( + SystemOutboxRecord( + id=uuid.uuid4(), + channel="discord", + payload={"content": content, "channel_key": channel_key}, + status="pending", + correlation_id=correlation_id, + source_type="priority_feed", + source_id=None, + ) + ) + await self.session.flush() + + logger.info( + "priority_feed_digest_enqueued", + correlation_id=str(correlation_id), + items=len(findings), + channel_key=channel_key, + ) + return correlation_id + + async def _load_priority_findings( + self, finding_ids: list[uuid.UUID], min_score: int + ) -> list[ResearchFindingRecord]: + """Load the findings that exist and meet the importance threshold, highest score first.""" + stmt = ( + select(ResearchFindingRecord) + .where(ResearchFindingRecord.id.in_(finding_ids)) + .where(ResearchFindingRecord.importance_score >= min_score) + .order_by(ResearchFindingRecord.importance_score.desc()) + ) + res = await self.session.execute(stmt) + return list(res.scalars().all()) + + def _render_digest(self, findings: list[ResearchFindingRecord], max_items: int) -> str: + """Render an influencer-style digest, mentioning the owner if the role policy requires it.""" + policy = self.router.policy(ChannelRole.PRIORITY_FEED) + shown = findings[:max_items] + remainder = len(findings) - len(shown) + + lines: list[str] = [] + header = f"🚨 **Priority Feed** — {len(findings)} high-signal drop{'s' if len(findings) != 1 else ''}" + if policy.mention_owner: + mention = self._owner_mention() + if mention: + header = f"{header} {mention}" + lines.append(header) + lines.append("") + + for idx, f in enumerate(shown, start=1): + score = f.importance_score or 0 + source = f.source or "unknown" + lines.append(f"**{idx}. {f.title}** · 🔥 {score}/5 · `{source}`") + if f.summary: + lines.append(self._first_line(f.summary)) + if f.url: + lines.append(f"🔗 {f.url}") + lines.append("") + + if remainder > 0: + lines.append(f"_…and {remainder} more in the next briefing._") + + return "\n".join(lines).strip() + + def _owner_mention(self) -> str: + """Build a Discord mention string for the configured owner(s).""" + owner_ids = self.settings.discord.owner_ids + return " ".join(f"<@{oid}>" for oid in owner_ids) + + @staticmethod + def _first_line(summary: str) -> str: + """Collapse a (possibly multi-line/bulleted) summary to a single punchy line.""" + for raw in summary.splitlines(): + line = raw.strip().lstrip("-*• ").strip() + if line: + return line if len(line) <= 240 else line[:237] + "…" + return "" diff --git a/nexus/intelligence/openrouter.py b/nexus/intelligence/openrouter.py index 402b44e..97b9677 100644 --- a/nexus/intelligence/openrouter.py +++ b/nexus/intelligence/openrouter.py @@ -53,8 +53,18 @@ def _build_providers(self) -> list[tuple[str, str, str, list[str]]]: )) return providers - async def complete(self, prompt: str, system_prompt: str | None = None) -> str: - """Post a completion across the multi-provider fallback chain (first success wins).""" + async def complete( + self, + prompt: str, + system_prompt: str | None = None, + history: list[dict[str, str]] | None = None, + ) -> str: + """Post a completion across the multi-provider fallback chain (first success wins). + + ``history`` is an optional ordered list of prior ``{"role": "user"|"assistant", + "content": ...}`` turns inserted between the system prompt and the current ``prompt`` so + multi-turn chat keeps context. + """ import time providers = self._build_providers() @@ -66,6 +76,8 @@ async def complete(self, prompt: str, system_prompt: str | None = None) -> str: messages: list[dict[str, str]] = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) + if history: + messages.extend(history) messages.append({"role": "user", "content": prompt}) last_error: Exception | None = None diff --git a/nexus/scheduling/jobs.py b/nexus/scheduling/jobs.py index 48e75bc..c40de04 100644 --- a/nexus/scheduling/jobs.py +++ b/nexus/scheduling/jobs.py @@ -96,16 +96,26 @@ async def run_scheduled_job( async def run_research_job(session_factory: Any, openrouter_client: Any, settings: Any) -> None: - """J1 — crawl configured research feeds via ResearchService. Skips if no feeds configured.""" + """J1 — crawl configured research feeds via ResearchService, then push the high-importance + new findings to the proactive Priority Feed. Skips if no feeds configured. + + Composition only: ResearchService persists findings (Discord-agnostic), then + PriorityFeedService routes the high-signal subset via the channel harness onto the outbox. + """ feeds = dict(settings.scheduling.research_feeds or {}) if not feeds: raise JobSkippedError("no research feeds configured") + from nexus.intelligence.feed import PriorityFeedService from nexus.intelligence.research import ResearchService async with get_session(session_factory) as session: memory_service = MemoryService(session) service = ResearchService(session, openrouter_client, memory_service) - await service.execute_research_run(feeds) + persisted_ids = await service.execute_research_run(feeds) + + if settings.scheduling.priority_feed_enabled and persisted_ids: + feed = PriorityFeedService(session, settings) + await feed.dispatch_new_findings(persisted_ids) async def run_briefing_job( diff --git a/tests/e2e/test_channel_harness.py b/tests/e2e/test_channel_harness.py new file mode 100644 index 0000000..8f0eafd --- /dev/null +++ b/tests/e2e/test_channel_harness.py @@ -0,0 +1,68 @@ +"""E2E tests for the transport-independent channel harness (roles, routing, policy).""" + +from __future__ import annotations + +from nexus.communication.channels import ( + ChannelMessage, + ChannelRole, + ChannelRouter, +) +from nexus.config import DiscordChannels + + +def _router() -> ChannelRouter: + return ChannelRouter( + DiscordChannels( + general="general", + console="console", + priority_feed="priority-feed", + reminders="reminders", + summaries="nexus-reports", + approvals="nexus-approvals", + ) + ) + + +def test_chat_channel_responds_without_mention() -> None: + router = _router() + assert router.respond_without_mention("general") is True + assert router.respond_without_mention("random-channel") is False + assert router.respond_without_mention(None) is False + + +def test_inbound_name_maps_to_semantic_role() -> None: + router = _router() + assert router.role_for_channel_name("general") is ChannelRole.CHAT + assert router.role_for_channel_name("priority-feed") is ChannelRole.PRIORITY_FEED + assert router.role_for_channel_name("console") is ChannelRole.SYSTEM + assert router.role_for_channel_name("unmapped") is None + + +def test_outbound_role_resolves_to_bound_channel() -> None: + router = _router() + assert router.channel_name(ChannelRole.SYSTEM) == "console" + assert router.channel_name(ChannelRole.PRIORITY_FEED) == "priority-feed" + assert router.channel_name(ChannelRole.BRIEFING) == "nexus-reports" + assert router.channel_key(ChannelRole.CHAT) == "general" + + +def test_priority_and_notification_mention_owner_but_chat_does_not() -> None: + router = _router() + assert router.policy(ChannelRole.PRIORITY_FEED).mention_owner is True + assert router.policy(ChannelRole.NOTIFICATION).mention_owner is True + assert router.policy(ChannelRole.CHAT).mention_owner is False + assert router.policy(ChannelRole.CHAT).respond_without_mention is True + assert router.policy(ChannelRole.SYSTEM).post_only is True + + +def test_channel_message_is_transport_neutral() -> None: + msg = ChannelMessage( + author="42", + channel_id="100", + conversation_id="100", + message="hello", + ) + assert msg.role is ChannelRole.CHAT # default + assert msg.metadata == {} + # Round-trips as plain data (no platform types). + assert msg.model_dump()["message"] == "hello" diff --git a/tests/e2e/test_chat_orchestration.py b/tests/e2e/test_chat_orchestration.py new file mode 100644 index 0000000..12f4a67 --- /dev/null +++ b/tests/e2e/test_chat_orchestration.py @@ -0,0 +1,133 @@ +"""E2E tests for the chat pipeline: Conversation → Planner → Validator → Executor. + +Uses an in-memory fake LLM and fake email service so the whole pipeline is exercised end-to-end, +deterministically, without network or Discord. +""" + +from __future__ import annotations + +from typing import Any + +from nexus.communication.channels import ChannelRole +from nexus.communication.chat import ( + ChatActionType, + ChatService, + Executor, + Planner, + Validator, +) + + +class FakeLLM: + """Deterministic LLM stub that records calls and returns scripted JSON plans.""" + + def __init__(self, responses: list[str]) -> None: + self._responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + async def complete( + self, + prompt: str, + system_prompt: str | None = None, + history: list[dict[str, str]] | None = None, + ) -> str: + self.calls.append({"prompt": prompt, "history": list(history or [])}) + return self._responses.pop(0) if self._responses else '{"type": "reply", "message": "ok"}' + + +class FakeEmail: + """Captures emails instead of sending them.""" + + def __init__(self) -> None: + self.sent: list[dict[str, str]] = [] + + async def send_briefing_email(self, subject: str, text: str, html: str) -> None: + self.sent.append({"subject": subject, "text": text, "html": html}) + + +def _service(responses: list[str], email: FakeEmail | None = None) -> ChatService: + return ChatService.build( + llm_client=FakeLLM(responses), + email_service=email or FakeEmail(), + owner_email="owner@example.com", + ) + + +async def test_reply_path_does_not_touch_services() -> None: + email = FakeEmail() + svc = _service(['{"type": "reply", "message": "Hello operator"}'], email) + resp = await svc.handle_text(conversation_id="c1", text="hi", is_owner=True) + assert resp.action_type is ChatActionType.REPLY + assert resp.executed is True + assert resp.reply == "Hello operator" + assert email.sent == [] + + +async def test_owner_can_send_email_end_to_end() -> None: + email = FakeEmail() + svc = _service( + ['{"type": "send_email", "subject": "Claude Login", "body": "https://claude.ai/login"}'], + email, + ) + resp = await svc.handle_text(conversation_id="c1", text="mail me the claude login url", is_owner=True) + + assert resp.action_type is ChatActionType.SEND_EMAIL + assert resp.executed is True + assert len(email.sent) == 1 + assert email.sent[0]["subject"] == "Claude Login" + assert "claude.ai/login" in email.sent[0]["text"] + # A SYSTEM status card is emitted for the side-effecting action. + assert any(p.role is ChannelRole.SYSTEM and p.card and p.card["verification"] == "sent" for p in resp.posts) + + +async def test_non_owner_email_is_denied_by_governance() -> None: + email = FakeEmail() + svc = _service( + ['{"type": "send_email", "subject": "x", "body": "secret"}'], + email, + ) + resp = await svc.handle_text(conversation_id="c1", text="email me", is_owner=False) + + assert resp.executed is False + assert email.sent == [] # never reached the executor + assert "owner" in (resp.reply or "").lower() + + +async def test_missing_required_field_fails_schema_validation() -> None: + email = FakeEmail() + # send_email with no body — must fail schema before execution. + svc = _service(['{"type": "send_email", "subject": "only subject"}'], email) + resp = await svc.handle_text(conversation_id="c1", text="mail me", is_owner=True) + + assert resp.executed is False + assert email.sent == [] + assert "missing" in (resp.reply or "").lower() + + +async def test_conversation_memory_is_passed_on_next_turn() -> None: + llm = FakeLLM( + [ + '{"type": "reply", "message": "Sure, I will remember Claude login is at claude.ai."}', + '{"type": "reply", "message": "It is claude.ai/login."}', + ] + ) + svc = ChatService(planner=Planner(llm), validator=Validator(), executor=Executor()) + await svc.handle_text(conversation_id="c9", text="remember the claude login", is_owner=True) + await svc.handle_text(conversation_id="c9", text="yah share", is_owner=True) + + # The 2nd planner call must have received the prior turns as history (the "yah share" fix). + second_history = llm.calls[1]["history"] + assert any(h["role"] == "user" and "remember" in h["content"] for h in second_history) + assert any(h["role"] == "assistant" for h in second_history) + + +async def test_governance_flags_are_stamped_server_side_not_by_llm() -> None: + # Even if the model omits governance flags, the planner stamps them from the trusted table. + planner = Planner(FakeLLM(['{"type": "send_email", "subject": "s", "body": "b"}'])) + action = await planner.plan("mail me", history=[]) + assert action.requires_owner is True + assert action.requires_approval is False + + planner2 = Planner(FakeLLM(['{"type": "reply", "message": "hi"}'])) + reply_action = await planner2.plan("hi", history=[]) + assert reply_action.requires_owner is False diff --git a/tests/e2e/test_priority_feed.py b/tests/e2e/test_priority_feed.py new file mode 100644 index 0000000..f07ebe6 --- /dev/null +++ b/tests/e2e/test_priority_feed.py @@ -0,0 +1,154 @@ +"""E2E tests for the proactive Priority Feed. + +Exercises the full path with a real database session: +research findings → PriorityFeedService (importance filter + influencer digest + channel routing) +→ transactional outbox → outbox delivery resolves to the #priority-feed channel, mentioning owner. + +No Discord, no network: a fake discord service captures which channel each post targets. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import select + +from nexus.gateway.communication_outbox import ( + _deliver_discord_chunks, + flush_outbox_synchronously, +) +from nexus.intelligence.feed import PriorityFeedService +from nexus.memory.models import ResearchFindingRecord, SystemOutboxRecord + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from nexus.config import NexusSettings + + +class FakeDiscord: + """Captures (channel_key, content) instead of touching Discord.""" + + def __init__(self) -> None: + self.posts: list[tuple[str, str]] = [] + + async def post_message(self, channel_key: str, content: str | None = None, **_: object) -> None: + self.posts.append((channel_key, content or "")) + + +async def _add_finding( + session: AsyncSession, *, title: str, score: int, url: str = "", source: str = "hackernews" +) -> uuid.UUID: + rec = ResearchFindingRecord( + id=uuid.uuid4(), + source=source, + title=title, + url=url, + summary="- A concise bullet point.\n- Another detail.", + tags=["ai"], + importance_score=score, + ) + session.add(rec) + await session.flush() + return rec.id + + +async def test_high_importance_finding_is_queued_to_priority_feed( + db_session: AsyncSession, test_settings: NexusSettings +) -> None: + hi = await _add_finding(db_session, title="OpenAI custom chip", score=5, url="https://x.test/a") + lo = await _add_finding(db_session, title="Minor blog post", score=2, url="https://x.test/b") + + feed = PriorityFeedService(db_session, test_settings) + corr = await feed.dispatch_new_findings([hi, lo]) + + assert corr is not None + rows = ( + (await db_session.execute(select(SystemOutboxRecord).where(SystemOutboxRecord.correlation_id == corr))) + .scalars() + .all() + ) + assert len(rows) == 1 + row = rows[0] + assert row.channel == "discord" + assert row.source_type == "priority_feed" + # Routed via the channel harness to the priority feed channel key. + assert row.payload["channel_key"] == "priority_feed" + # The high-importance item is present; the low one is filtered out. + assert "OpenAI custom chip" in row.payload["content"] + assert "Minor blog post" not in row.payload["content"] + # Owner is mentioned (PRIORITY_FEED policy.mention_owner is True). + assert "<@111222333>" in row.payload["content"] + + +async def test_no_dispatch_when_nothing_clears_threshold( + db_session: AsyncSession, test_settings: NexusSettings +) -> None: + lo1 = await _add_finding(db_session, title="low one", score=1) + lo2 = await _add_finding(db_session, title="low two", score=3) + + feed = PriorityFeedService(db_session, test_settings) + corr = await feed.dispatch_new_findings([lo1, lo2]) + + assert corr is None + rows = (await db_session.execute(select(SystemOutboxRecord))).scalars().all() + assert rows == [] + + +async def test_disabled_feed_does_not_dispatch( + db_session: AsyncSession, test_settings: NexusSettings +) -> None: + test_settings.scheduling.priority_feed_enabled = False + hi = await _add_finding(db_session, title="huge news", score=5) + + feed = PriorityFeedService(db_session, test_settings) + assert await feed.dispatch_new_findings([hi]) is None + + +async def test_digest_caps_items_and_summarizes_remainder( + db_session: AsyncSession, test_settings: NexusSettings +) -> None: + test_settings.scheduling.priority_feed_max_items = 2 + ids = [ + await _add_finding(db_session, title=f"finding {i}", score=5, url=f"https://x.test/{i}") + for i in range(5) + ] + + feed = PriorityFeedService(db_session, test_settings) + corr = await feed.dispatch_new_findings(ids) + assert corr is not None + + row = ( + (await db_session.execute(select(SystemOutboxRecord).where(SystemOutboxRecord.correlation_id == corr))) + .scalars() + .one() + ) + content = row.payload["content"] + assert content.count("🔥 5/5") == 2 # only max_items rendered in full + assert "and 3 more" in content + + +async def test_outbox_delivers_priority_feed_to_correct_channel( + db_session: AsyncSession, test_settings: NexusSettings +) -> None: + """The generalized outbox routes the queued digest to #priority-feed, not #summaries.""" + hi = await _add_finding(db_session, title="ground-breaking", score=5, url="https://x.test/z") + feed = PriorityFeedService(db_session, test_settings) + corr = await feed.dispatch_new_findings([hi]) + assert corr is not None + + discord = FakeDiscord() + await flush_outbox_synchronously(db_session, corr, discord, email_service=None) + + assert discord.posts, "expected a delivery" + channel_key, content = discord.posts[0] + assert channel_key == "priority_feed" + assert "ground-breaking" in content + + +async def test_legacy_discord_payload_defaults_to_summaries(db_session: AsyncSession) -> None: + """A payload without channel_key still targets 'summaries' (backward compatibility).""" + discord = FakeDiscord() + await _deliver_discord_chunks(discord, "legacy briefing body") + assert discord.posts == [("summaries", "legacy briefing body")] From b88b3c8898fca865a42f0e5023282c68c0070205 Mon Sep 17 00:00:00 2001 From: Nexus Project <nexus@project.local> Date: Fri, 26 Jun 2026 13:03:19 +0530 Subject: [PATCH 2/3] feat(dex-v2): wire create_task, run_research, show_status executors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining chat actions now call real domain services instead of returning 'not wired up yet'. The Executor gains an injected session_factory and event_gateway (provided at startup in api.py) and opens a session per action, mirroring the /task_create slash command path — no duplicated logic. - create_task: TaskService.create_task -> change_status(QUEUED); emits lifecycle events via the gateway. Owner-gated. - run_research: creates and queues a research task routed to the 'nexus' agent runtime (topic preserved as description) — responsive and async. Owner-gated. - show_status: live counts from the DB (health, open tasks, pending approvals, 24h research findings). Open to all. - approval_request: unchanged — short-circuits at the Validator (requires_approval) before reaching the executor. Each side-effecting action emits a SYSTEM status card routed to #console via the channel harness, and every branch is fail-soft (missing config or a service error returns an honest reply + failed card, never crashes). Architecture held: thin adapter, governance stamped server-side in the planner's trusted table, validator schema gates unchanged. Tests: +5 E2E (real DB session factory, scripted LLM, no service mocks): owner create_task persists+queues; non-owner denied persists nothing; missing title schema-fails; run_research queues a nexus-runtime task; show_status reflects seeded counts. Gate: 241 passed, ruff clean, mypy clean (69 files). --- nexus/api.py | 2 + nexus/communication/chat/executor.py | 218 +++++++++++++++++++++++++-- nexus/communication/chat/service.py | 9 +- tests/e2e/test_chat_actions.py | 129 ++++++++++++++++ 4 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/test_chat_actions.py diff --git a/nexus/api.py b/nexus/api.py index 8d245eb..069a8e1 100644 --- a/nexus/api.py +++ b/nexus/api.py @@ -148,6 +148,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: llm_client=openrouter_client, email_service=email_service, owner_email=settings.email.to_address, + session_factory=_state.session_factory, + event_gateway=event_gateway, ) discord_bot = NexusBot( settings, diff --git a/nexus/communication/chat/executor.py b/nexus/communication/chat/executor.py index 44ee672..213fbc3 100644 --- a/nexus/communication/chat/executor.py +++ b/nexus/communication/chat/executor.py @@ -1,16 +1,19 @@ """Executor — performs a validated :class:`ChatAction` via injected services. Responsibility boundary: the executor decides *how* to carry out an action. It calls domain services -(EmailService, …) that are injected, emits a SYSTEM status card for side-effecting actions, and -returns a transport-neutral :class:`ChatResponse`. It performs no governance decisions (the Validator -already approved) and no LLM calls. +(EmailService, TaskService, ResearchService, …) that are injected or constructed per-operation from +an injected ``session_factory``, emits a SYSTEM status card for side-effecting actions, and returns a +transport-neutral :class:`ChatResponse`. It performs no governance decisions (the Validator already +approved) and no LLM calls. """ from __future__ import annotations +from datetime import UTC, datetime, timedelta from typing import Any import structlog +from sqlalchemy import func, select from nexus.communication.channels import ChannelRole from nexus.communication.chat.contracts import ( @@ -22,13 +25,24 @@ logger = structlog.get_logger("nexus.communication.chat.executor") +# Runtime the research task is routed to (the Nexus agent has search/research tools). +_RESEARCH_RUNTIME_ID = "nexus" + class Executor: """Executes validated actions against injected domain services.""" - def __init__(self, email_service: Any = None, owner_email: str = "") -> None: + def __init__( + self, + email_service: Any = None, + owner_email: str = "", + session_factory: Any = None, + event_gateway: Any = None, + ) -> None: self.email_service = email_service self.owner_email = owner_email + self.session_factory = session_factory + self.event_gateway = event_gateway async def execute(self, action: ChatAction) -> ChatResponse: """Dispatch the action; return a transport-neutral response.""" @@ -40,13 +54,20 @@ async def execute(self, action: ChatAction) -> ChatResponse: ) if action.type is ChatActionType.SEND_EMAIL: return await self._send_email(action) - # Recognized but not yet wired to a domain service — honest, non-crashing response. + if action.type is ChatActionType.CREATE_TASK: + return await self._create_task(action) + if action.type is ChatActionType.RUN_RESEARCH: + return await self._run_research(action) + if action.type is ChatActionType.SHOW_STATUS: + return await self._show_status(action) + # Anything still unmapped (e.g. approval_request is handled upstream) — honest, non-crashing. return ChatResponse( reply=f"That action (`{action.type.value}`) is recognized but not wired up yet.", action_type=action.type, executed=False, ) + # ------------------------------------------------------------------ email async def _send_email(self, action: ChatAction) -> ChatResponse: subject = str(action.payload.get("subject") or "Message from Nexus").strip() body = str(action.payload.get("body") or "").strip() @@ -57,7 +78,7 @@ async def _send_email(self, action: ChatAction) -> ChatResponse: reply="⚠️ Email is not configured.", action_type=ChatActionType.SEND_EMAIL, executed=False, - posts=[_card(subject, "failed")], + posts=[_email_card(subject, "failed")], ) try: html = ( @@ -71,18 +92,175 @@ async def _send_email(self, action: ChatAction) -> ChatResponse: reply=f"❌ Email failed: {e!s}", action_type=ChatActionType.SEND_EMAIL, executed=False, - posts=[_card(subject, "failed")], + posts=[_email_card(subject, "failed")], ) logger.info("executor_email_sent", recipient=recipient, subject=subject) return ChatResponse( reply=f"📧 Sent to **{recipient}** — *{subject}*", action_type=ChatActionType.SEND_EMAIL, executed=True, - posts=[_card(subject, "sent")], + posts=[_email_card(subject, "sent")], + ) + + # ------------------------------------------------------------- create_task + async def _create_task(self, action: ChatAction) -> ChatResponse: + title = str(action.payload.get("title") or "").strip() + description = action.payload.get("description") + priority = _as_int(action.payload.get("priority"), default=2) + + if self.session_factory is None: + return ChatResponse( + reply="⚠️ Task creation is not configured.", + action_type=ChatActionType.CREATE_TASK, + executed=False, + posts=[_task_card(title, "create_task", "failed")], + ) + try: + task_id, status = await self._persist_task(title, description, priority) + except Exception as e: + logger.error("executor_create_task_failed", error=str(e)) + return ChatResponse( + reply=f"❌ Couldn't create the task: {e!s}", + action_type=ChatActionType.CREATE_TASK, + executed=False, + posts=[_task_card(title, "create_task", "failed")], + ) + logger.info("executor_task_created", task_id=str(task_id), title=title) + return ChatResponse( + reply=f"✅ Task created & queued — **{title}**\n`{task_id}` · status `{status}`", + action_type=ChatActionType.CREATE_TASK, + executed=True, + posts=[_task_card(title, "create_task", "queued")], + ) + + # ------------------------------------------------------------ run_research + async def _run_research(self, action: ChatAction) -> ChatResponse: + topic = str(action.payload.get("topic") or "").strip() + + if self.session_factory is None: + return ChatResponse( + reply="⚠️ Research is not configured.", + action_type=ChatActionType.RUN_RESEARCH, + executed=False, + posts=[_task_card(topic, "run_research", "failed")], + ) + try: + task_id, status = await self._persist_task( + title=f"Research: {topic}", + description=topic, + priority=2, + runtime_id=_RESEARCH_RUNTIME_ID, + ) + except Exception as e: + logger.error("executor_run_research_failed", error=str(e)) + return ChatResponse( + reply=f"❌ Couldn't queue research: {e!s}", + action_type=ChatActionType.RUN_RESEARCH, + executed=False, + posts=[_task_card(topic, "run_research", "failed")], + ) + logger.info("executor_research_queued", task_id=str(task_id), topic=topic) + return ChatResponse( + reply=f"🔬 Queued a research task on **{topic}** for the agent.\n`{task_id}` · status `{status}`", + action_type=ChatActionType.RUN_RESEARCH, + executed=True, + posts=[_task_card(topic, "run_research", "queued")], ) + # ------------------------------------------------------------- show_status + async def _show_status(self, action: ChatAction) -> ChatResponse: + from nexus.core.health import get_health_reason, is_healthy + + healthy = is_healthy() + liveness = "🟢 HEALTHY" if healthy else f"🔴 UNHEALTHY ({get_health_reason()})" + open_tasks = pending_approvals = findings_24h = 0 + + if self.session_factory is not None: + try: + open_tasks, pending_approvals, findings_24h = await self._status_counts() + except Exception as e: # status must never crash the chat + logger.error("executor_show_status_failed", error=str(e)) -def _card(subject: str, verification: str) -> OutboundPost: + reply = ( + f"**Nexus status** — {liveness}\n" + f"• Open tasks: `{open_tasks}`\n" + f"• Pending approvals: `{pending_approvals}`\n" + f"• Research findings (24h): `{findings_24h}`" + ) + card = OutboundPost( + role=ChannelRole.SYSTEM, + card={ + "title": "Dex • Status", + "risk": "LOW", + "plan": f"Liveness {('OK' if healthy else 'DEGRADED')} · " + f"{open_tasks} tasks · {pending_approvals} approvals · {findings_24h} findings/24h", + "tools": "show_status", + "verification": "sent" if healthy else "failed", + }, + ) + return ChatResponse( + reply=reply, + action_type=ChatActionType.SHOW_STATUS, + executed=True, + posts=[card], + ) + + # ----------------------------------------------------------------- helpers + async def _persist_task( + self, + title: str, + description: Any = None, + priority: int = 2, + runtime_id: str = "gemini", + ) -> tuple[Any, str]: + """Create and enqueue a task via TaskService; return (id, status). Same path as /task_create.""" + from nexus.core.types import TaskStatus + from nexus.database import get_session + from nexus.memory.service import MemoryService + from nexus.memory.task_service import TaskService + + async with get_session(self.session_factory) as session: + memory_service = MemoryService(session) + task_service = TaskService(session, memory_service, self.event_gateway) + task = await task_service.create_task( + title=title, + description=str(description) if description is not None else None, + priority=priority, + runtime_id=runtime_id, + ) + updated = await task_service.change_status(task.id, TaskStatus.QUEUED) + return updated.id, updated.status + + async def _status_counts(self) -> tuple[int, int, int]: + """Return (open_tasks, pending_approvals, research_findings_24h).""" + from nexus.database import get_session + from nexus.memory.models import ( + ApprovalRecord, + ResearchFindingRecord, + TaskRecord, + ) + + past_24h = datetime.now(UTC) - timedelta(hours=24) + async with get_session(self.session_factory) as session: + open_tasks = await session.scalar( + select(func.count()) + .select_from(TaskRecord) + .where(TaskRecord.status.in_(["created", "queued", "active", "blocked"])) + ) + pending_approvals = await session.scalar( + select(func.count()) + .select_from(ApprovalRecord) + .where(ApprovalRecord.status == "pending") + ) + findings_24h = await session.scalar( + select(func.count()) + .select_from(ResearchFindingRecord) + .where(ResearchFindingRecord.discovered_at >= past_24h) + ) + return int(open_tasks or 0), int(pending_approvals or 0), int(findings_24h or 0) + + +def _email_card(subject: str, verification: str) -> OutboundPost: """Build a SYSTEM-channel status card for an email action.""" return OutboundPost( role=ChannelRole.SYSTEM, @@ -94,3 +272,25 @@ def _card(subject: str, verification: str) -> OutboundPost: "verification": verification, }, ) + + +def _task_card(subject: str, tool: str, verification: str) -> OutboundPost: + """Build a SYSTEM-channel status card for a task/research action.""" + return OutboundPost( + role=ChannelRole.SYSTEM, + card={ + "title": "Dex • Task Action", + "risk": "LOW", + "plan": f"{tool}: {subject}"[:1000], + "tools": tool, + "verification": verification, + }, + ) + + +def _as_int(value: Any, default: int) -> int: + """Coerce an LLM-provided value to int, falling back to a default.""" + try: + return int(value) + except (TypeError, ValueError): + return default diff --git a/nexus/communication/chat/service.py b/nexus/communication/chat/service.py index 7c83fd3..75de064 100644 --- a/nexus/communication/chat/service.py +++ b/nexus/communication/chat/service.py @@ -44,12 +44,19 @@ def build( llm_client: Any, email_service: Any = None, owner_email: str = "", + session_factory: Any = None, + event_gateway: Any = None, ) -> ChatService: """Convenience constructor wiring the default pipeline from services.""" return cls( planner=Planner(llm_client), validator=Validator(), - executor=Executor(email_service=email_service, owner_email=owner_email), + executor=Executor( + email_service=email_service, + owner_email=owner_email, + session_factory=session_factory, + event_gateway=event_gateway, + ), ) def history_for(self, conversation_id: str) -> list[dict[str, str]]: diff --git a/tests/e2e/test_chat_actions.py b/tests/e2e/test_chat_actions.py new file mode 100644 index 0000000..e60f40c --- /dev/null +++ b/tests/e2e/test_chat_actions.py @@ -0,0 +1,129 @@ +"""E2E tests for the wired chat executors: create_task, run_research, show_status. + +Drives the full pipeline (Planner → Validator → Executor) with a scripted fake LLM and a REAL +database session factory, then asserts the side effects landed in the database — no Discord, no +network, no mocks of the domain services. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from nexus.communication.chat import ChatActionType, ChatService +from nexus.database import get_session +from nexus.memory.models import ResearchFindingRecord, TaskRecord + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + + +class FakeLLM: + """Returns one scripted JSON plan.""" + + def __init__(self, response: str) -> None: + self._response = response + + async def complete( + self, + prompt: str, + system_prompt: str | None = None, + history: list[dict[str, str]] | None = None, + ) -> str: + return self._response + + +def _factory(db_engine: AsyncEngine) -> async_sessionmaker[Any]: + return async_sessionmaker(db_engine, expire_on_commit=False) + + +def _service(db_engine: AsyncEngine, response: str) -> ChatService: + return ChatService.build( + llm_client=FakeLLM(response), + session_factory=_factory(db_engine), + event_gateway=None, + ) + + +async def test_owner_create_task_persists_and_queues(db_engine: AsyncEngine) -> None: + svc = _service( + db_engine, + '{"type": "create_task", "title": "Ship the feed", "description": "wire it", "priority": 1}', + ) + resp = await svc.handle_text(conversation_id="c1", text="create a task to ship the feed", is_owner=True) + + assert resp.action_type is ChatActionType.CREATE_TASK + assert resp.executed is True + assert any(p.card and p.card["verification"] == "queued" for p in resp.posts) + + factory = _factory(db_engine) + async with factory() as session: + rows = (await session.execute(select(TaskRecord).where(TaskRecord.title == "Ship the feed"))).scalars().all() + assert len(rows) == 1 + assert rows[0].status == "queued" + assert rows[0].priority == 1 + + +async def test_non_owner_create_task_denied_and_persists_nothing(db_engine: AsyncEngine) -> None: + svc = _service(db_engine, '{"type": "create_task", "title": "sneaky", "priority": 2}') + resp = await svc.handle_text(conversation_id="c1", text="make a task", is_owner=False) + + assert resp.executed is False + assert "owner" in (resp.reply or "").lower() + factory = _factory(db_engine) + async with factory() as session: + rows = (await session.execute(select(TaskRecord).where(TaskRecord.title == "sneaky"))).scalars().all() + assert rows == [] + + +async def test_create_task_missing_title_fails_schema(db_engine: AsyncEngine) -> None: + svc = _service(db_engine, '{"type": "create_task", "description": "no title here"}') + resp = await svc.handle_text(conversation_id="c1", text="create a task", is_owner=True) + + assert resp.executed is False + assert "missing" in (resp.reply or "").lower() + + +async def test_owner_run_research_queues_task_for_agent(db_engine: AsyncEngine) -> None: + svc = _service(db_engine, '{"type": "run_research", "topic": "RISC-V accelerators"}') + resp = await svc.handle_text(conversation_id="c1", text="research risc-v accelerators", is_owner=True) + + assert resp.action_type is ChatActionType.RUN_RESEARCH + assert resp.executed is True + + factory = _factory(db_engine) + async with factory() as session: + row = ( + (await session.execute(select(TaskRecord).where(TaskRecord.title == "Research: RISC-V accelerators"))) + .scalars() + .one() + ) + assert row.status == "queued" + assert row.runtime_id == "nexus" # routed to the research-capable agent + assert row.description == "RISC-V accelerators" + + +async def test_show_status_reports_counts(db_engine: AsyncEngine) -> None: + # Seed one open task and one recent finding so the status reflects real DB state. + factory = _factory(db_engine) + async with get_session(factory) as session: + session.add(TaskRecord(id=uuid.uuid4(), title="open one", status="queued", priority=2)) + session.add( + ResearchFindingRecord( + id=uuid.uuid4(), source="hn", title="a finding", url="https://x.test/1", + summary="s", tags=["ai"], importance_score=5, + ) + ) + + svc = _service(db_engine, '{"type": "show_status"}') + resp = await svc.handle_text(conversation_id="c1", text="status?", is_owner=False) + + assert resp.action_type is ChatActionType.SHOW_STATUS + assert resp.executed is True + assert "status" in (resp.reply or "").lower() + assert "Open tasks: `1`" in (resp.reply or "") + assert "Research findings (24h): `1`" in (resp.reply or "") + assert any(p.card and p.card["tools"] == "show_status" for p in resp.posts) From 6369129e3e392d05818b35a5b2f8d8e0c9c0b166 Mon Sep 17 00:00:00 2001 From: Nexus Project <nexus@project.local> Date: Fri, 26 Jun 2026 13:28:49 +0530 Subject: [PATCH 3/3] design(email): Nexus v2 email design system & HTML template architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-first milestone. Establishes the email visual identity of Nexus v2 as a reusable, production-quality, Jinja2 + table-based HTML system. No business logic, no service wiring, no EmailService changes — pure design assets. Foundation - base.html master layout: 600px table shell, mso ghost tables, inline-CSS-first (the <style> block carries only dark-mode + responsive media queries), hidden preheader, accent + content blocks. - 9 component partials as Jinja2 macros: header, footer, section (+panel, code_block), metric_card (+metric_row), badge, status_chip, divider (+spacer), button (+VML button_row), timeline, table (data_table, kv_grid, progress). Email types (extend base.html, one accent each) - 12 operational: morning_digest, operational_intelligence, research_report, todo_digest, reminder, approval_required, execution_completed, execution_failed, security_alert, scheduler_report, weekly_review, monthly_executive. - 4 conversational (Q&A family): qa_transcript, conversation_summary, action_items, decision_summary (remaining formats documented as presets). Documentation - EMAIL_DESIGN_SYSTEM.md — philosophy, architecture, semantic + subsystem colour system, type scale, spacing/radius/elevation, icon strategy, placeholder schema, future-extension strategy (Discord/Slack/Teams/web/PDF). - EMAIL_STYLE_GUIDE.md — voice & tone, colour/writing rules, accent-per-type, 55 subject-line patterns, accessibility checklist. - EMAIL_COMPONENT_LIBRARY.md — per-component signatures, params, examples, variants, higher-order patterns, Q&A family mapping. - EMAIL_TEMPLATE_GUIDELINES.md — authoring model, client-compatibility matrix, responsive + dark-mode rules, charts approach, per-type specifications, rendering instructions, design-only integration seam, QA checklist. Verification & extras - sample_context.json: concrete placeholder payloads (the data schema). - previews/: 3 rendered HTML examples. - All 16 templates verified to render via Jinja2 with realistic data, light/dark and desktop/mobile, with zero unresolved tags. Compatibility: Gmail, Outlook (Word/VML), Apple Mail; dark mode; mobile-first responsive; no external images, web fonts, or JS. --- .../templates/EMAIL_COMPONENT_LIBRARY.md | 213 +++++ .../email/templates/EMAIL_DESIGN_SYSTEM.md | 275 ++++++ .../email/templates/EMAIL_STYLE_GUIDE.md | 215 +++++ .../templates/EMAIL_TEMPLATE_GUIDELINES.md | 218 +++++ nexus/communication/email/templates/base.html | 126 +++ .../templates/emails/approval_required.html | 67 ++ .../emails/conversations/action_items.html | 38 + .../conversations/conversation_summary.html | 84 ++ .../conversations/decision_summary.html | 34 + .../emails/conversations/qa_transcript.html | 51 ++ .../templates/emails/execution_completed.html | 71 ++ .../templates/emails/execution_failed.html | 75 ++ .../templates/emails/monthly_executive.html | 66 ++ .../templates/emails/morning_digest.html | 139 +++ .../emails/operational_intelligence.html | 81 ++ .../email/templates/emails/reminder.html | 39 + .../templates/emails/research_report.html | 68 ++ .../templates/emails/scheduler_report.html | 43 + .../templates/emails/security_alert.html | 62 ++ .../email/templates/emails/todo_digest.html | 57 ++ .../email/templates/emails/weekly_review.html | 62 ++ .../email/templates/partials/badge.html | 21 + .../email/templates/partials/button.html | 51 ++ .../email/templates/partials/divider.html | 19 + .../email/templates/partials/footer.html | 68 ++ .../email/templates/partials/header.html | 75 ++ .../email/templates/partials/metric_card.html | 50 ++ .../email/templates/partials/section.html | 69 ++ .../email/templates/partials/status_chip.html | 35 + .../email/templates/partials/table.html | 64 ++ .../email/templates/partials/timeline.html | 42 + .../previews/approval_required.preview.html | 436 ++++++++++ .../previews/execution_failed.preview.html | 515 +++++++++++ .../previews/morning_digest.preview.html | 799 ++++++++++++++++++ .../email/templates/sample_context.json | 104 +++ 35 files changed, 4432 insertions(+) create mode 100644 nexus/communication/email/templates/EMAIL_COMPONENT_LIBRARY.md create mode 100644 nexus/communication/email/templates/EMAIL_DESIGN_SYSTEM.md create mode 100644 nexus/communication/email/templates/EMAIL_STYLE_GUIDE.md create mode 100644 nexus/communication/email/templates/EMAIL_TEMPLATE_GUIDELINES.md create mode 100644 nexus/communication/email/templates/base.html create mode 100644 nexus/communication/email/templates/emails/approval_required.html create mode 100644 nexus/communication/email/templates/emails/conversations/action_items.html create mode 100644 nexus/communication/email/templates/emails/conversations/conversation_summary.html create mode 100644 nexus/communication/email/templates/emails/conversations/decision_summary.html create mode 100644 nexus/communication/email/templates/emails/conversations/qa_transcript.html create mode 100644 nexus/communication/email/templates/emails/execution_completed.html create mode 100644 nexus/communication/email/templates/emails/execution_failed.html create mode 100644 nexus/communication/email/templates/emails/monthly_executive.html create mode 100644 nexus/communication/email/templates/emails/morning_digest.html create mode 100644 nexus/communication/email/templates/emails/operational_intelligence.html create mode 100644 nexus/communication/email/templates/emails/reminder.html create mode 100644 nexus/communication/email/templates/emails/research_report.html create mode 100644 nexus/communication/email/templates/emails/scheduler_report.html create mode 100644 nexus/communication/email/templates/emails/security_alert.html create mode 100644 nexus/communication/email/templates/emails/todo_digest.html create mode 100644 nexus/communication/email/templates/emails/weekly_review.html create mode 100644 nexus/communication/email/templates/partials/badge.html create mode 100644 nexus/communication/email/templates/partials/button.html create mode 100644 nexus/communication/email/templates/partials/divider.html create mode 100644 nexus/communication/email/templates/partials/footer.html create mode 100644 nexus/communication/email/templates/partials/header.html create mode 100644 nexus/communication/email/templates/partials/metric_card.html create mode 100644 nexus/communication/email/templates/partials/section.html create mode 100644 nexus/communication/email/templates/partials/status_chip.html create mode 100644 nexus/communication/email/templates/partials/table.html create mode 100644 nexus/communication/email/templates/partials/timeline.html create mode 100644 nexus/communication/email/templates/previews/approval_required.preview.html create mode 100644 nexus/communication/email/templates/previews/execution_failed.preview.html create mode 100644 nexus/communication/email/templates/previews/morning_digest.preview.html create mode 100644 nexus/communication/email/templates/sample_context.json diff --git a/nexus/communication/email/templates/EMAIL_COMPONENT_LIBRARY.md b/nexus/communication/email/templates/EMAIL_COMPONENT_LIBRARY.md new file mode 100644 index 0000000..a8b1901 --- /dev/null +++ b/nexus/communication/email/templates/EMAIL_COMPONENT_LIBRARY.md @@ -0,0 +1,213 @@ +# Nexus Email Component Library + +The reusable building blocks. Every email is composed from these — authors +should reach for an existing component before writing bespoke HTML. Each entry +lists the import, signature, parameters, an example, variants, and rendering +notes. + +> Import a partial once at the top of your `{% block content %}`: +> `{% import "partials/metric_card.html" as mc %}` +> Header and footer are auto-included by `base.html`; you don't import them. + +--- + +## Layout & shell + +### `base.html` — master layout +- **Blocks:** `title`, `preheader`, `accent` (top-bar hex = personality), `content`. +- **Provides:** `<head>`, dark-mode + responsive `<style>`, page→container→card + scaffold, header/footer includes, preheader text. +- **Use:** `{% extends "base.html" %}` then override `accent` + `content`. + +### `partials/header.html` — brand lockup +- **Renders:** monogram tile + "Nexus" wordmark, right-aligned `timestamp`, + `eyebrow`, `subject` (`<h1>`), `subtitle`, hairline. +- **Context:** `eyebrow, subject, subtitle, timestamp, accent`. +- **Notes:** logo is a bulletproof CSS tile (no image). Emits `<tr>` rows. + +### `partials/footer.html` — closer +- **Renders:** `brand_links`, brand line + `version`, automation note, generated-at. +- **Context:** `brand_links, version, footer_note, timestamp`. + +--- + +## Content components + +### Section — `section.html` → `sec` +Titled content block with accent tick + optional eyebrow. Call form. + +```jinja +{% import "partials/section.html" as sec %} +{% call sec.section("Today's health", eyebrow="System", accent="#16A34A") %} + ...inner html... +{% endcall %} +``` +- **Params:** `title`, `eyebrow=None`, `accent="#4F46E5"`; body via `{{ caller() }}`. + +### Panel — `section.html` → `sec.panel` +Soft tinted callout for info/success/warning/error/neutral. + +```jinja +{% call sec.panel("warning", title="Throughput dip") %}−8% vs 7-day avg.{% endcall %} +``` +- **Params:** `tone="info"` (`info·success·warning·error·neutral`), `title=None`. +- **Notes:** left accent bar + leading glyph; use for alerts, root cause, TL;DR. + +### Code / artifact block — `section.html` → `sec.code_block` +Monospace dark surface for logs, stack traces, file lists, raw events. + +```jinja +{{ sec.code_block(incident.stderr, label="stderr") }} +``` +- **Params:** `content`, `label=None`. Wraps long lines; safe on mobile. + +### Metric card — `metric_card.html` → `mc` +Single KPI, or a responsive row that stacks on mobile (**prefer the row**). + +```jinja +{% import "partials/metric_card.html" as mc %} +{{ mc.metric_row([ + {"label":"Uptime","value":"99.98%","delta":"+0.04","trend":"up"}, + {"label":"Tasks","value":"24","trend":"flat"}, + {"label":"Failures","value":"1","delta":"-2","trend":"down-good","accent":"#DC2626"} +]) }} +``` +- **`metric_card(label, value, delta=None, trend='flat', accent='#0F172A')`** +- **`metric_row(items)`** — up to 3 across; `.nx-stack` collapses to full-width rows ≤ 600px. +- **trend:** `up`(green▲) `down`(red▼) `flat`(→) `up-bad`(red▲) `down-good`(green▼). + +### Timeline — `timeline.html` → `tl` +Vertical event sequence (scheduler runs, failure stages, audit, conversation). + +```jinja +{% import "partials/timeline.html" as tl %} +{{ tl.timeline([ + {"time":"08:00","title":"Briefing dispatched","tone":"success","body":"3 channels"}, + {"time":"10:00","title":"Research run","tone":"info"} +]) }} +``` +- **Params:** `events=[{time,title,body?,tone?}]`. tone → dot colour + (`success·warning·danger·info·pending·neutral`). + +### Data table — `table.html` → `tbl.data_table` +Bordered, header-styled table with per-column alignment. + +```jinja +{% import "partials/table.html" as tbl %} +{{ tbl.data_table(columns=["Job","Status","Duration"], + rows=[["research","Succeeded","12s"],["sweep","Skipped","—"]], + aligns=["left","left","right"]) }} +``` +- **Params:** `columns`, `rows` (list of cell lists), `aligns=None`. + +### Key/value grid — `table.html` → `tbl.kv_grid` +Metadata block (definition-list style); label left, value right. + +```jinja +{{ tbl.kv_grid([["Runtime","nexus"],["Repository","workspace_root"]]) }} +``` +- **Params:** `pairs=[[key,value], ...]`. + +### Progress bar — `table.html` → `tbl.progress` +Completion / budget / "chart bar". Bulletproof (table fill, no CSS gradients). + +```jinja +{{ tbl.progress(72, "brand", "Step budget") }} +``` +- **Params:** `pct`, `tone="brand"` (`brand·success·warning·danger·info`), `label=None`. +- **Notes:** clamps 0–100. Stacked bars approximate charts in email; real charts + ship as pre-rendered images in the PDF export. + +--- + +## Indicators & actions + +### Badge — `badge.html` → `bdg.badge` +Static rounded label (tags, subsystem, IDs). + +```jinja +{% import "partials/badge.html" as bdg %} +{{ bdg.badge("Governance", "brand") }} {{ bdg.badge("CVE-2026-1", "danger", mono=True) }} +``` +- **Params:** `text`, `tone="neutral"` (`neutral·brand·success·warning·danger·info·pending`), `mono=False`. + +### Status chip — `status_chip.html` → `chip.status_chip` +A state with a leading status dot (liveness, task/job state, risk). + +```jinja +{% import "partials/status_chip.html" as chip %} +{{ chip.status_chip("Healthy", "success") }} +{{ chip.status_chip("MEDIUM RISK", "warning") }} +``` +- **Params:** `text`, `status="info"` (`success·warning·danger·info·pending·neutral`). + +### Divider / spacer — `divider.html` → `rule` +Consistent vertical rhythm. + +```jinja +{% import "partials/divider.html" as rule %} +{{ rule.divider() }} {# hairline + 20px above/below #} +{{ rule.spacer(24) }} {# invisible gap only #} +``` +- **`divider(space=20)`**, **`spacer(space=16)`**. + +### Button — `button.html` → `btn` +Bulletproof CTA (VML for Outlook), full-width-on-mobile, and a button group. + +```jinja +{% import "partials/button.html" as btn %} +{{ btn.button("Open dashboard", url, "primary") }} +{{ btn.button_row([ + {"label":"✓ Approve","href":a,"variant":"success"}, + {"label":"✕ Reject","href":r,"variant":"danger-outline"} +]) }} +``` +- **`button(label, href, variant='primary', full=False)`** — variants: + `primary·neutral·success·danger·danger-outline·ghost`. +- **`button_row(buttons)`** — stacks vertically ≤ 600px. + +--- + +## Higher-order patterns (compositions) + +These aren't separate files — they're conventional compositions of the above, +used across templates and worth standardising: + +| Pattern | Built from | Seen in | +|---|---|---| +| **Status card** | `panel` + `status_chip` + `kv_grid` | approval, security, completed | +| **Event card** | white card + title + `badge` + caption + link | morning digest (research) | +| **Task card** | `data_table` row or card + `badge` priority | todo, digest | +| **Approval card** | `panel` + risk `chip` + `kv_grid` + `code_block(files)` + `button_row` | approval_required | +| **Incident report** | `panel(error)` + `kv_grid` + `timeline` + `code_block` + numbered recovery | execution_failed | +| **Statistics panel** | `metric_row` + `progress` bars | operational, weekly, monthly | +| **Info/Alert/Warning/Error/Success panels** | `panel(tone)` | everywhere | +| **Link preview** | event card with title + source `badge` + "Read →" | research, digest | +| **Priority / severity indicator** | `status_chip` + `badge` | approval, security | +| **Key-value grid** | `kv_grid` | metadata blocks | +| **Chat bubble** | aligned table + tinted `<td>` | qa_transcript | + +--- + +## Q&A / conversational family mapping + +Four template files cover the whole conversational family; the rest are +**presets of these** (same structure, different `eyebrow`/`subject`/emphasis): + +| Requested type | Use template | Notes | +|---|---|---| +| Q&A transcript | `conversations/qa_transcript.html` | chat bubbles, operator vs Dex | +| Conversation summary | `conversations/conversation_summary.html` | TL;DR + key points + decisions + actions | +| Meeting notes | `conversation_summary` | eyebrow "Meeting Notes"; topics = agenda | +| AI session recap | `conversation_summary` | eyebrow "Session Recap" | +| Knowledge capture | `conversation_summary` | lead with `key_points`; add `references` | +| Daily conversation digest | `conversation_summary` | eyebrow "Conversation Digest"; multiple topics | +| Research discussion | `conversation_summary` | accent `#0EA5E9`; emphasise insights | +| Decision summary | `conversations/decision_summary.html` | per-decision rationale + alternatives | +| Action items | `conversations/action_items.html` | checklist with owner/due/priority | +| Follow-ups | `action_items` | summary_line "Follow-ups"; lower priority tone | +| Operator notes | `conversation_summary` | free-form `key_points` only | + +This keeps the surface small and consistent while covering every requested +conversational format. New conversational variants should reuse one of these +four rather than adding files. diff --git a/nexus/communication/email/templates/EMAIL_DESIGN_SYSTEM.md b/nexus/communication/email/templates/EMAIL_DESIGN_SYSTEM.md new file mode 100644 index 0000000..a951cdf --- /dev/null +++ b/nexus/communication/email/templates/EMAIL_DESIGN_SYSTEM.md @@ -0,0 +1,275 @@ +# Nexus Email Design System + +> The visual identity of Nexus v2, expressed through email. +> **Design-first artifact.** Nothing here is wired into `EmailService`, business +> logic, or the runtime. These are pure design assets (Jinja2 + HTML) plus the +> documentation that governs them. + +--- + +## 1. Philosophy + +Nexus is an **AI Orchestration Control Plane** — mission control for autonomous +work. Its email must read like an instrument panel, not a newsletter. Every +message should communicate **operational intelligence, reliability, and calm +authority**. + +The reference points are product surfaces, never marketing ones: + +| We aim for | We avoid | +|---|---| +| Stripe · Linear · GitHub · Vercel · Notion · OpenAI · Anthropic | Promotional newsletters | +| Restraint, whitespace, hierarchy | Gradient-heavy hero banners | +| Soft borders, rounded cards, soft shadows | Glassmorphism, neon, drop-shadow stacks | +| Semantic colour with meaning | Decorative colour | +| Data density that stays scannable | Bootstrap email kits | + +**Design principles** + +1. **Signal over decoration.** Colour, weight, and space carry meaning. If a + pixel doesn't help the operator decide or act, remove it. +2. **One language, many personalities.** Every email shares the same shell, + type scale, and components. Each *type* gets a single **accent colour** and a + tailored composition — that is its entire personality budget. +3. **Bulletproof first.** It must render in Gmail, Outlook (Word engine), and + Apple Mail before it is allowed to be beautiful. Progressive enhancement, not + graceful degradation. +4. **Dark-mode native.** Light is the source of truth; dark is a first-class + peer, not an afterthought. +5. **Accessible by construction.** Contrast, semantics, and real text are + defaults, not add-ons. + +--- + +## 2. Architecture + +``` +templates/ + base.html Master layout (the shell every email extends) + partials/ Reusable component macros + header/footer includes + header.html footer.html + section.html metric_card.html badge.html status_chip.html + divider.html button.html timeline.html table.html + emails/ One file per email TYPE (extends base.html) + morning_digest.html operational_intelligence.html research_report.html + todo_digest.html reminder.html approval_required.html + execution_completed.html execution_failed.html security_alert.html + scheduler_report.html weekly_review.html monthly_executive.html + conversations/ The Q&A / conversational family + qa_transcript.html conversation_summary.html + action_items.html decision_summary.html + previews/ Rendered HTML examples (open in a browser) + sample_context.json Concrete placeholder payloads (the data schema) + EMAIL_DESIGN_SYSTEM.md EMAIL_STYLE_GUIDE.md + EMAIL_COMPONENT_LIBRARY.md EMAIL_TEMPLATE_GUIDELINES.md +``` + +**Templating engine: Jinja2** (already a dependency, `3.1.x`). Chosen over MJML +or a hand-rolled string builder because it is native to the Python stack, needs +no new toolchain, supports `{% extends %}` / `{% block %}` / `{% macro %}` / +`{% include %}`, and the rendered output is plain inline-styled HTML. + +**Inheritance model** + +- `base.html` owns the `<head>`, the non-inlinable `<style>` block (dark mode + + responsive only), the page/container/card scaffolding, and the header/footer + includes. +- Each email **extends** `base.html`, overrides `{% block accent %}` (its + personality colour) and `{% block content %}` (its composition). +- Components are **macros** imported inside the content block, e.g. + `{% import "partials/metric_card.html" as mc %}`. + +--- + +## 3. Colour system + +Colour is **semantic**. A colour is only used when it means something. + +### 3.1 Neutrals (the 95%) + +| Token | Light | Dark | Use | +|---|---|---|---| +| `page` | `#F1F5F9` | `#0B0F19` | Outer canvas behind the card | +| `surface` | `#FFFFFF` | `#111827` | Card / panel background | +| `surface-muted` | `#F8FAFC` | `#0F1623` | Metric cards, kv grids, code labels | +| `border` | `#E2E8F0` | `#1F2937` | Hairlines, card edges | +| `border-strong` | `#CBD5E1` | `#374151` | Emphasis dividers | +| `ink` (heading) | `#0F172A` | `#F9FAFB` | Titles, key values | +| `body` | `#334155` | `#CBD5E1` | Paragraph text | +| `muted` | `#64748B` | `#9CA3AF` | Secondary text, labels | +| `faint` | `#94A3B8` | `#6B7280` | Timestamps, captions | + +### 3.2 Brand + +| Token | Hex | Use | +|---|---|---| +| `brand-navy` | `#0F172A` | Logo tile, executive reports, wordmark | +| `brand-accent` (indigo) | `#4F46E5` | Primary CTA, links, default accent | +| `brand-accent-soft` | `#EEF2FF` | Accent backgrounds, brand badges | + +### 3.3 Semantic states + +| State | Solid | Text-on-soft | Soft bg | Soft border | +|---|---|---|---|---| +| Success | `#16A34A` | `#15803D` | `#ECFDF5` | `#A7F3D0` | +| Warning | `#D97706` | `#B45309` | `#FFFBEB` | `#FDE68A` | +| Danger | `#DC2626` | `#B91C1C` | `#FEF2F2` | `#FECACA` | +| Info | `#2563EB` | `#1D4ED8` | `#EFF6FF` | `#BFDBFE` | +| Pending | `#7C3AED` | `#6D28D9` | `#F5F3FF` | `#EDE9FE` | +| Neutral | `#64748B` | `#475569` | `#F1F5F9` | `#E2E8F0` | + +### 3.4 Subsystem hues (accents per Nexus domain) + +Used as section accents and email accent bars so a glance maps to a subsystem. + +| Subsystem | Hex | | Subsystem | Hex | +|---|---|---|---|---| +| Communication | `#4F46E5` | | Memory | `#7C3AED` | +| Research | `#0EA5E9` | | Sandbox | `#059669` | +| Execution | `#6366F1` | | Runtime | `#D97706` | +| Governance | `#DC2626` | | Scheduler | `#0891B2` | + +> **Contrast.** All text/background pairs above meet WCAG AA for their size class +> (≥ 4.5:1 body, ≥ 3:1 large/bold). See the Accessibility Checklist in +> `EMAIL_STYLE_GUIDE.md`. + +--- + +## 4. Typography + +System font stack — zero web-font dependency, native rendering everywhere: + +``` +-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif +``` + +Monospace (code, IDs, metrics-detail): + +``` +ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace +``` + +### Type scale + +| Role | Size / Line | Weight | Tracking | Colour | Notes | +|---|---|---|---|---|---| +| Hero | 32 / 40 | 700 | −0.6 | ink | Rare; landing-style headers | +| Title | 24 / 32 | 600 | −0.4 | ink | Email subject line in header | +| Subtitle | 15 / 24 | 400 | 0 | muted | One supporting sentence | +| Section | 16 / 22 | 600 | −0.2 | ink | `section()` heading | +| Eyebrow | 11–12 / 16 | 700 | +1.1 uppercase | faint/accent | Category label above a title | +| Body | 15 / 24 | 400 | 0 | body | Default reading text | +| Body-sm | 14 / 21 | 400 | 0 | body | Panels, cards | +| Caption | 13 / 20 | 400 | 0 | muted | Supporting detail | +| Metric | 28 / 34 | 600 | −0.6 | ink | KPI value (mobile → 24) | +| Status | 12 / 16 | 600 | +0.2 | semantic | Chips, badges | +| Timestamp | 11–12 / 16 | 500 | +0.2 | faint | Header, footer, timeline | + +Responsive: Hero/Title/Metric step down one level at ≤ 600px (see `base.html` +`.nx-hero`, `.nx-title`, `.nx-metric-value`). + +--- + +## 5. Spacing, radius, elevation + +**Spacing scale (px):** `4 · 8 · 12 · 16 · 20 · 24 · 32 · 40 · 48 · 64` +Section gutter is `32px` (desktop) → `20px` (mobile, via `.nx-px`). +Section rhythm uses `divider(space=20)` between blocks and `spacer(n)` for tuned gaps. + +**Radius:** `sm 6 · md 8 (buttons) · lg 10 (cards/panels) · xl 14 (outer card) · pill 999 (chips)` + +**Elevation:** one soft card shadow only — +`box-shadow: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.10)`. +No stacked shadows, no glow. Depth comes from borders + surface contrast. + +**Layout grid:** single 600px column, 32px side gutters → 536px content width. +Multi-up rows (metrics, buttons) use equal-width cells that **stack** at ≤ 600px +via the `.nx-stack` class. + +--- + +## 6. Iconography + +No external icon libraries, no remote images (privacy + reliability). Icons are: + +- **Unicode glyphs** for inline marks: `→ ● ▪ ★ ✓ ✕ ▲ ▼ ☐ ☑ ⏰ ↗ ↻ ℹ`. +- **CSS shapes** for status dots and the brand tile (rounded `<td>`s). +- The logo is a **bulletproof monogram** — a `#0F172A` rounded tile with a white + "N" — rendering identically across all clients and dark mode. (An optional + hosted PNG/retina logo can replace it later without structural change; see + `EMAIL_TEMPLATE_GUIDELINES.md` §Logo.) + +Glyph usage is consistent: `→` = next/recommendation, `✓` = done/success, +`✕` = reject/fail, `★` = importance/highlight, `●` = insight bullet, +`▪` = architecture note, `☐/☑` = action item. + +--- + +## 7. Email type catalogue + +Each type = base shell + one accent + a composition. Personality summary: + +| # | Template | Accent | Personality | +|---|---|---|---| +| 1 | `morning_digest` | Indigo `#4F46E5` | Confident daily briefing, action-first | +| 2 | `operational_intelligence` | Navy `#0F172A` | Dense executive report | +| 3 | `research_report` | Sky `#0EA5E9` | Sharp analyst briefing | +| 4 | `todo_digest` | Indigo `#4F46E5` | Productivity checklist | +| 5 | `reminder` | Purple `#7C3AED` | Minimal, single-focus | +| 6 | `approval_required` | Amber `#D97706` | High-stakes, unmistakable CTAs | +| 7 | `execution_completed` | Green `#16A34A` | Reassuring, evidence-backed | +| 8 | `execution_failed` | Red `#DC2626` | Calm forensic incident report | +| 9 | `security_alert` | Red `#DC2626` | Authoritative, action-first | +| 10 | `scheduler_report` | Teal `#0891B2` | Job run summary | +| 11 | `weekly_review` | Navy `#0F172A` | Executive dashboard | +| 12 | `monthly_executive` | Navy `#0F172A` | CEO-style narrative | +| Q&A | `conversations/*` | Indigo `#4F46E5` | Transcript / summary / actions / decisions | + +Full per-type specifications are in `EMAIL_TEMPLATE_GUIDELINES.md` §Specifications. + +--- + +## 8. Placeholder data schema + +Every template is driven by a plain context dict (no ORM types). Concrete, +copy-pasteable payloads for **all** templates live in +[`sample_context.json`](./sample_context.json). Shared keys consumed by the +shell (header/footer): + +| Key | Type | Meaning | +|---|---|---| +| `eyebrow` | str | Uppercase category label in the header | +| `subject` | str | Header title (and the email Subject; see Style Guide) | +| `subtitle` | str | One supporting sentence + inbox preheader | +| `timestamp` | str | Pre-formatted, e.g. `"Jun 26, 2026 · 08:00 IST"` | +| `version` | str | Footer build tag, e.g. `"v1.2.0"` | +| `brand_links` | list[{label, href}] | Footer navigation | +| `footer_note` | str? | Override the automation disclaimer | + +Type-specific keys are documented at the top of each template file and in +`sample_context.json`. + +--- + +## 9. Future extension strategy + +This is a **design language**, not just an email kit. The same tokens and +component semantics translate to every future Nexus channel: + +| Channel | How the language maps | +|---|---| +| **Discord embeds** | Accent → embed colour bar; `status_chip` → field with dot emoji; `metric_card` → inline fields; `code_block` → fenced block. Roles already map via the channel harness. | +| **Web dashboard cards** | Same tokens as CSS variables; macros become React/Vue components 1:1 (MetricCard, StatusChip, Timeline, Panel). | +| **Slack** | Block Kit: `section`→section block, `button_row`→actions block, `kv_grid`→fields, accent→context/colour. | +| **Microsoft Teams** | Adaptive Cards: panels→Containers with `style`, metrics→ColumnSet, chips→`TextBlock` + colour. | +| **Mobile push** | `subject` + `subtitle` are already the title/body; accent → notification colour. | +| **PDF reports** | The HTML renders to PDF (WeasyPrint/Chromium) as-is; charts swap from progress bars to embedded vector images. | + +**Token portability:** §3–§5 are the contract. Keep one source of truth for the +palette/scale; every channel implementation references these names, never raw +hex re-invented per surface. When a token changes here, it changes everywhere. + +**Governance:** new email types must (a) extend `base.html`, (b) pick exactly one +accent from §3.4, (c) reuse existing components before inventing new ones, and +(d) ship a `sample_context.json` entry + a preview. See the QA checklist in +`EMAIL_TEMPLATE_GUIDELINES.md`. diff --git a/nexus/communication/email/templates/EMAIL_STYLE_GUIDE.md b/nexus/communication/email/templates/EMAIL_STYLE_GUIDE.md new file mode 100644 index 0000000..ee20a6e --- /dev/null +++ b/nexus/communication/email/templates/EMAIL_STYLE_GUIDE.md @@ -0,0 +1,215 @@ +# Nexus Email Style Guide + +How Nexus emails should *sound*, *read*, and *behave*. Pairs with +`EMAIL_DESIGN_SYSTEM.md` (the visual tokens) and `EMAIL_COMPONENT_LIBRARY.md` +(the building blocks). + +--- + +## 1. Voice & tone + +Nexus speaks like a **senior operator briefing a peer**: precise, calm, +unhurried, never breathless. + +| Do | Don't | +|---|---| +| "All core subsystems are nominal." | "🎉 Great news!! Everything is awesome!" | +| "Execution failed at the compile stage." | "Oops! Something went wrong 😢" | +| "Authorization needed before execution." | "ACTION REQUIRED!!! CLICK NOW" | +| State facts, then the action. | Bury the action under prose. | +| Use numbers with units and deltas. | Vague qualifiers ("a lot", "soon"). | + +**Tone by type** + +- *Digest / reports*: composed, executive, scannable. +- *Approval / security*: serious, unambiguous, never alarmist — clarity reduces panic. +- *Failure*: forensic and constructive (cause → recovery), never apologetic theatre. +- *Reminder*: warm, brief, singular. +- *Conversational (Q&A)*: faithful and neutral; summarise, don't editorialise. + +**Person & tense.** Second person to the operator ("your control plane"), +present tense for state, past tense for events. Active voice always. + +--- + +## 2. Writing rules + +1. **Front-load the verdict.** First line = the conclusion (healthy / failed / + needs approval). Detail follows. +2. **One idea per section.** If a section needs an "and", it's two sections. +3. **Numbers are typeset.** Wrap IDs, counts, durations, and paths in the mono + or badge treatment so they're scannable. +4. **Deltas carry direction + semantics.** `+6 ▲` green for good, `−2 ▼` green + when "down is good" (failures), red when "down is bad". Use the `trend` + values `up · down · flat · up-bad · down-good`. +5. **No empty sections.** A template must hide a block when its data is absent + (`{% if %}` guards everywhere). Never render "No data". +6. **Links are verbs.** "Review →", "Read →", "Open dashboard" — not "click here". + +--- + +## 3. Colour usage rules + +- **Accent = identity, not emphasis.** Each email has exactly **one** accent + (its type's colour). Don't sprinkle multiple brand colours for decoration. +- **Semantic colour only on state.** Green/amber/red/blue/purple appear on + chips, panels, dots, and deltas — never as background fills for plain text. +- **Surfaces stay neutral.** Cards are white/`surface`; the muted surface is for + secondary grouping. Coloured backgrounds are reserved for `panel()` (soft tints). +- **One coloured CTA per decision.** Primary action solid; secondary as `ghost` + or `*-outline`. Two solid competing buttons only for Approve/Reject. +- **Never rely on colour alone.** Pair every state colour with a glyph or label + (✓/✕/▲, "HEALTHY", "MEDIUM RISK") for colour-blind and grayscale readers. + +--- + +## 4. Spacing & rhythm + +- Section gutter `32px` desktop / `20px` mobile — never hand-tune per template. +- Between major sections: `divider()` (hairline + 20px each side). +- Within a section: `spacer(10–16)`. +- Cards in a list: `8–10px` gap. +- Don't crowd CTAs: at least `divider()` above a final button row. + +--- + +## 5. Iconography rules + +- Use only the approved glyph set (`EMAIL_DESIGN_SYSTEM.md` §6). +- One glyph meaning, everywhere: `→` next, `✓` done, `✕` reject, `★` importance, + `●` insight, `▪` architecture, `☐/☑` action item, `⏰` time, `↻` retry. +- Emoji allowed sparingly and only where it adds scan-speed (`🚨` priority feed, + `📧` email sent, `🔬` research, `🟢/🔴` liveness). Never decorative emoji in + executive reports. + +--- + +## 6. Accent-per-type (authoritative) + +| Template | `{% block accent %}` | +|---|---| +| morning_digest, todo_digest, conversations/* | `#4F46E5` | +| operational_intelligence, weekly_review, monthly_executive | `#0F172A` | +| research_report | `#0EA5E9` | +| reminder | `#7C3AED` | +| approval_required | `#D97706` | +| execution_completed | `#16A34A` | +| execution_failed, security_alert | `#DC2626` | +| scheduler_report | `#0891B2` | +| action_items | `#D97706` | +| decision_summary | `#16A34A` | + +--- + +## 7. Subject line system + +**Format:** `[Nexus] <Title>` with an optional ` — <qualifier>`. Keep ≤ ~60 +chars so it doesn't truncate on mobile. Sentence case. No ALL-CAPS, no emoji in +the subject itself (emoji belongs in-body). Put the most decision-relevant token +early. Preheader (`subtitle`) extends, never repeats, the subject. + +**Conventions** + +- Status/severity goes after an em dash: `— Healthy`, `— MEDIUM risk`, `— failed`. +- Time-boxed reports name the window: `(24h)`, `(Wk 26)`, `(June 2026)`. +- Counts when they drive the open: `3 approvals pending`. + +### 50+ subject patterns + +**Morning / digest** +1. `[Nexus] Morning Operational Digest` +2. `[Nexus] Morning Digest — all systems nominal` +3. `[Nexus] Morning Digest — 1 alert, 3 approvals` +4. `[Nexus] Good morning — your control plane is ready` +5. `[Nexus] Daily Briefing — {date}` + +**Operational intelligence** +6. `[Nexus] Operational Intelligence Report` +7. `[Nexus] Operations — p95 latency up 12%` +8. `[Nexus] Operational Report (24h)` +9. `[Nexus] System Intelligence — degraded throughput` +10. `[Nexus] Control Plane Status — {date}` + +**Research** +11. `[Nexus] Research Intelligence` +12. `[Nexus] Research — {topic}` +13. `[Nexus] High-signal research: {headline}` +14. `[Nexus] Research Briefing — 20 new findings` +15. `[Nexus] Intelligence digest — {topic}` + +**TODO / productivity** +16. `[Nexus] Your TODO Digest` +17. `[Nexus] Today — 5 tasks, 1 blocked` +18. `[Nexus] TODOs — 2 due today` +19. `[Nexus] Your day, planned` + +**Reminder** +20. `[Nexus] Reminder — {reason}` +21. `[Nexus] Reminder — {reason} in 30 min` +22. `[Nexus] Don't forget: {reason}` +23. `[Nexus] Heads up — {reason} at {time}` + +**Approval** +24. `[Nexus] Approval Required` +25. `[Nexus] Approval Required — {task}` +26. `[Nexus] Approval Required — {risk} risk` +27. `[Nexus] Action needed — authorize {task}` +28. `[Nexus] 3 approvals awaiting you` +29. `[Nexus] Approval expires in 4h — {task}` + +**Execution completed** +30. `[Nexus] Execution Completed — {task}` +31. `[Nexus] {task} finished in {duration}` +32. `[Nexus] Done — {task} (2 artifacts)` +33. `[Nexus] Task complete — {task}` + +**Execution failed** +34. `[Nexus] Execution Failed — {task}` +35. `[Nexus] {task} failed at {stage}` +36. `[Nexus] Incident — {task} ({exit_status})` +37. `[Nexus] Failure — {task}, recovery suggested` + +**Security** +38. `[Nexus] Security Alert — {category}` +39. `[Nexus] {severity} severity — {category} violation` +40. `[Nexus] Governance — policy triggered` +41. `[Nexus] Sandbox blocked an action` + +**Scheduler** +42. `[Nexus] Scheduler Report (24h)` +43. `[Nexus] Scheduler — 1 job failed` +44. `[Nexus] Jobs summary — 18 ran, 2 skipped` + +**Weekly / monthly** +45. `[Nexus] Weekly Operational Review (Wk {n})` +46. `[Nexus] Weekly Operations — {date_range}` +47. `[Nexus] Monthly Executive Report — {month}` +48. `[Nexus] {month} in review` +49. `[Nexus] Quarterly operations summary` + +**Conversational / Q&A** +50. `[Nexus] Conversation Summary — {topic}` +51. `[Nexus] Session recap — {date}` +52. `[Nexus] Q&A transcript — {topic}` +53. `[Nexus] Action items from your session ({n})` +54. `[Nexus] Decision summary — {topic}` +55. `[Nexus] Follow-ups from {date}` + +--- + +## 8. Accessibility checklist + +Every template must pass before it ships: + +- [ ] **Contrast** — body text ≥ 4.5:1; large/bold ≥ 3:1 (tokens in §3 comply). +- [ ] **Real text** — no text baked into images; the body is selectable & translatable. +- [ ] **Semantic structure** — one `<h1>` (header title), `<h2>` per section, `<pre>` for code. +- [ ] **`lang` set** — `<html lang="en">`; localisable. +- [ ] **Colour never alone** — every state has a glyph or label too. +- [ ] **Link text is descriptive** — "Review →", not "here". +- [ ] **Tap targets** — buttons ≥ 42px tall; full-width on mobile. +- [ ] **Preheader present** — meaningful inbox preview, not repeated subject. +- [ ] **Reading order** — single column; DOM order = visual order for screen readers. +- [ ] **Dark mode** — verified in both schemes; no invisible text. +- [ ] **Reduced clutter** — tables marked `role="presentation"` so AT doesn't announce layout tables as data. +- [ ] **alt text** — any decorative shape is empty/`role=presentation`; any future content image carries real `alt`. diff --git a/nexus/communication/email/templates/EMAIL_TEMPLATE_GUIDELINES.md b/nexus/communication/email/templates/EMAIL_TEMPLATE_GUIDELINES.md new file mode 100644 index 0000000..064ca93 --- /dev/null +++ b/nexus/communication/email/templates/EMAIL_TEMPLATE_GUIDELINES.md @@ -0,0 +1,218 @@ +# Nexus Email Template Guidelines + +Practical rules for authoring, extending, rendering, and QA-ing Nexus email +templates. Pairs with the Design System (tokens), Style Guide (voice), and +Component Library (blocks). + +--- + +## 1. Anatomy of a template + +Every email type is a child of `base.html`: + +```jinja +{% extends "base.html" %} +{% block accent %}#4F46E5{% endblock %} {# personality colour #} +{% block content %} + {% import "partials/section.html" as sec %} {# import what you use #} + {% import "partials/divider.html" as rule %} + {% call sec.section("Summary", eyebrow="Overview") %} + {{ summary_line }} + {% endcall %} + {{ rule.divider() }} + ... +{% endblock %} +``` + +Rules: + +- **Override only `accent` + `content`.** The shell owns everything else. +- **Import inside `content`.** Top-of-file imports in a child template are not + reliably executed by Jinja's inheritance; importing inside the block is the + safe, tested pattern. +- **Header/footer data comes from context** (`eyebrow`, `subject`, `subtitle`, + `timestamp`, `version`, `brand_links`) — don't render them yourself. +- **Guard every section** with `{% if data %}` so absent data produces no empty + block. Never print "No data". +- **Compose, don't hand-roll.** Use Component Library macros; only drop to raw + table HTML for a genuinely new pattern (then consider promoting it to a macro). + +--- + +## 2. Email-client compatibility + +Targets and the techniques that satisfy them: + +| Client | Engine | Key constraints handled | +|---|---|---| +| Gmail (web/iOS/Android) | Blink-ish, **strips `<style>` selectors it dislikes, no `class` in some cases** | All visual styling is **inline**; `<style>` carries only media queries | +| Apple Mail (macOS/iOS) | WebKit | Full support; dark-mode media query honoured | +| Outlook (Win, 2016–2021) | **Word (mso)** | Ghost tables for the container, **VML round-rect buttons**, `mso-table-lspace/rspace`, no border-radius on Outlook (degrades to square — acceptable) | +| Outlook.com / 365 web | Blink | Inline styles; fine | +| Yahoo / Proton / Fastmail | Mixed | Table layout + inline styles cover them | + +Non-negotiables baked into the system: + +- **Table-based layout**, `role="presentation"`, `cellpadding=0 cellspacing=0 border=0`. +- **Inline styles** on every visual element; `<style>` only for what can't inline + (dark mode, responsive, a few resets). +- **600px** centred container with an **mso ghost table** wrapper. +- **VML buttons** so Outlook gets real, clickable, rounded CTAs. +- **No external images / web fonts / JS** — privacy, reliability, and no + "load images" wall. +- **`mso-hide:all`** + zero-height preheader for inbox preview. + +--- + +## 3. Responsive guidelines + +Mobile-first content in a fixed 600px shell that adapts down: + +- The container is `width:600px; max-width:600px` and becomes `100%` at ≤ 600px + (`.nx-container`). +- Side gutters shrink `32 → 20px` via `.nx-px`. +- **Multi-column rows stack**: any cell with `.nx-stack` becomes + `display:block; width:100%` at ≤ 600px. This drives `metric_row` and + `button_row` to vertical layout. Spacer cells (`.nx-stack-gap`, `.nx-hide-sm`) + hide on mobile. +- Type steps down: `.nx-hero 32→26`, `.nx-title 24→21`, `.nx-metric-value 28→24`. +- Buttons go full-width (`.nx-btn a { width:100% }`). +- Touch targets ≥ 42px tall. + +Breakpoint: a single `@media screen and (max-width:600px)` in `base.html`. +(Outlook ignores media queries but already shows the fixed 600px desktop layout, +which is correct for it.) + +--- + +## 4. Dark mode + +- `<meta name="color-scheme">` + `<meta name="supported-color-schemes">` declare + support; `@media (prefers-color-scheme: dark)` remaps surfaces/ink/borders. +- `[data-ogsc]` overrides cover Outlook.com's dark transform. +- Authoring rule: use the **semantic classes** (`nx-ink`, `nx-body`, `nx-muted`, + `nx-card`, `nx-surface`, `nx-muted-surface`, `nx-border`) on elements whose + colour must flip. Inline light colours remain the source of truth; the media + query overrides them in dark. +- Never set a hard white background on text without a matching dark class. + +--- + +## 5. Charts in email + +Email can't run JS/canvas, and remote chart images are blocked by default. The +system therefore uses **bulletproof bar/sparkline views** built from +`tbl.progress(...)` for trends and budgets — they render everywhere, in dark +mode, with no external load. + +For richer, true charts (time series, distributions): + +- Render server-side to a **static image** and attach it (or inline as a `cid:` + attachment), with descriptive `alt`. +- Reserve the slot in-template with a labelled caption (see + `operational_intelligence.html` / `weekly_review.html`), so the layout is + identical whether the bar view or the image is shown. +- The **PDF export** path (see §8) swaps progress bars for vector charts. + +--- + +## 6. Template specifications + +Per-type intent, required context, and components used. Full payloads in +`sample_context.json`; per-file context docs are in each template's header comment. + +| # | Template | Sections (in order) | Primary components | +|---|---|---|---| +| 1 | `morning_digest` | Exec summary · metrics · health · research · tasks · approvals · scheduler · runtime · alerts · recommendations · quick actions | metric_row, event cards, data_table, panel, timeline, status_chip, button_row | +| 2 | `operational_intelligence` | Exec summary · metrics · performance(bars) · architecture · failures · recovery · recommendations · appendix | metric_row, progress, kv_grid, data_table, timeline | +| 3 | `research_report` | Headline+importance · summary · insights · sources · recommendations · actions | section, status_chip, badge, data_table, button_row | +| 4 | `todo_digest` | Today · upcoming · blocked · completed · quick links | data_table, panel, button_row | +| 5 | `reminder` | Time chip · reason · context · suggested action · CTA | status_chip, section, panel, button | +| 6 | `approval_required` | Authorization panel · risk · metadata · reason · scope · files · Approve/Reject · expiry | panel, status_chip, kv_grid, code_block, button_row | +| 7 | `execution_completed` | Result chip · metadata · metrics · artifacts · output · logs | status_chip, kv_grid, metric_row, artifact cards, code_block | +| 8 | `execution_failed` | Failure panel · metadata · timeline · root cause · logs/stack · recovery · retry | panel, kv_grid, timeline, code_block, button_row | +| 9 | `security_alert` | Violation panel · severity · details · evidence · operator actions · acknowledge | panel, status_chip, badge, kv_grid, code_block, button | +| 10 | `scheduler_report` | Health · metrics · job runs · timeline | status_chip, metric_row, data_table, timeline | +| 11 | `weekly_review` | KPIs · trends(bars) · reliability · execution · LLM usage · cost · recommendations | metric_row, progress, kv_grid | +| 12 | `monthly_executive` | Highlights · KPIs · growth(bars) · reliability · execution · research · architecture · recommendations · roadmap | metric_row, progress, kv_grid, timeline | +| Q&A | `conversations/qa_transcript` | participants · chat turns | chat bubbles, badge | +| Q&A | `conversations/conversation_summary` | TL;DR · topics · key points · decisions · action items · follow-ups · references | panel, badge, checklist, section | +| Q&A | `conversations/action_items` | summary · item cards (owner/due/priority/done) | item cards, badge, button | +| Q&A | `conversations/decision_summary` | per-decision: status · decision · rationale · alternatives | section, status_chip, badge, panel | + +--- + +## 7. Rendering & previewing + +Render any template with Jinja2 + the sample context (design-time only): + +```python +import json +from jinja2 import Environment, FileSystemLoader, select_autoescape + +root = "nexus/communication/email/templates" +env = Environment(loader=FileSystemLoader(root), + autoescape=select_autoescape(["html"])) +ctx = json.load(open(f"{root}/sample_context.json")) +shared = ctx["_shared"] + +html = env.get_template("emails/morning_digest.html").render( + **shared, **ctx["morning_digest"]) +open("preview.html", "w", encoding="utf-8").write(html) +``` + +- Pre-rendered examples live in [`previews/`](./previews) — open them in a + browser (toggle OS dark mode to verify both schemes). +- For client testing, paste rendered HTML into Litmus / Email on Acid, or send + to a Gmail + Outlook + Apple Mail test inbox. +- Sanity assertion used in design QA: rendered output contains **no** `{{` or + `{%` (all tags resolved). + +--- + +## 8. Integration note (design-only — not wired) + +This milestone deliberately does **not** modify `EmailService` or wire templates +into any service. When integration happens later, the seam is intentionally thin: + +- Add a small renderer (e.g. `EmailRenderer`) that owns a Jinja2 `Environment` + pointed at this `templates/` dir and exposes `render(template, context) -> html`. +- Producers (briefing, approval, execution, scheduler) build a **plain context + dict** (per `sample_context.json`) and pass `(template_name, context)`. +- `EmailService.send_briefing_email(subject, text, html)` stays unchanged — it + receives the rendered `html`; the `text` part is generated from the same + context for the multipart/alternative fallback. +- The accent/role mapping already aligns with the **channel harness**, so the + same context can fan out to Discord embeds later (Design System §9). + +No code in this package imports services; it is safe to ship as pure assets. + +--- + +## 9. Logo + +Default is a **bulletproof CSS monogram** (navy rounded tile + white "N") — no +asset, perfect dark-mode behaviour. To upgrade to a wordmark image later: + +- Host a 2× PNG/SVG on a stable CDN; swap the monogram `<td>` in + `partials/header.html` for an `<img>` with fixed `width/height`, `alt="Nexus"`, + and a dark-mode swap via `@media`/`[data-ogsc]`. +- Keep the monogram as the fallback for image-blocked clients. + +--- + +## 10. Authoring QA checklist + +Before a new/changed template ships: + +- [ ] Extends `base.html`; overrides only `accent` + `content`. +- [ ] Exactly one accent, taken from Design System §3.4 / Style Guide §6. +- [ ] Reuses Component Library macros; no duplicated bespoke HTML. +- [ ] Every section guarded by `{% if %}`; no empty blocks. +- [ ] All visual styles inline; nothing new added to `<style>` except global needs. +- [ ] Renders with its `sample_context.json` entry — output has no unresolved tags. +- [ ] Verified in **light and dark**, **desktop and mobile** widths. +- [ ] Buttons present as VML in Outlook (uses `btn.button`/`btn.button_row`). +- [ ] Subject + preheader follow Style Guide §7 (≤ ~60 chars, non-duplicate preheader). +- [ ] Accessibility checklist (Style Guide §8) passes. +- [ ] A preview added to `previews/` and a context entry to `sample_context.json`. diff --git a/nexus/communication/email/templates/base.html b/nexus/communication/email/templates/base.html new file mode 100644 index 0000000..a46b3fa --- /dev/null +++ b/nexus/communication/email/templates/base.html @@ -0,0 +1,126 @@ +{#- + Nexus Email Design System — Master Layout (base.html) + ---------------------------------------------------------------------------- + Design-first artifact. NOT wired into EmailService. Renders with Jinja2. + + Architecture + • Table-based, 600px centered container, mobile-first. + • Inline styles on every visual element (Gmail strips <style> selectors it + doesn't support). The <style> block carries ONLY what cannot be inlined: + dark-mode + responsive media queries and a few client resets. + • Outlook (mso) ghost tables + VML handled in partials/button.html. + • Dark mode via prefers-color-scheme; light is the source of truth. + + Design tokens (single source of truth — mirrored in EMAIL_DESIGN_SYSTEM.md) + Surface #FFFFFF page bg #F1F5F9 muted surface #F8FAFC + Border #E2E8F0 border-strong #CBD5E1 + Ink (heading) #0F172A body #334155 muted #64748B faint #94A3B8 + Brand (navy) #0F172A accent (indigo) #4F46E5 accent-soft #EEF2FF + Success #16A34A Warning #D97706 Danger #DC2626 Info #2563EB Pending #7C3AED + Radius sm 6 · md 8 · lg 12 · pill 999 + Shadow card: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.10) + + Blocks a child template overrides + title · preheader · accent (top bar hex) · content + Context variables consumed + eyebrow · subject · subtitle · timestamp · version · brand_links · footer_note +-#} +<!DOCTYPE html> +<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="x-apple-disable-message-reformatting"> + <meta name="color-scheme" content="light dark"> + <meta name="supported-color-schemes" content="light dark"> + <title>{% block title %}{{ subject | default('Nexus') }}{% endblock %} + + + + + {# ---- preheader: inbox preview text, visually hidden ---- #} +
+ {% block preheader %}{{ subtitle | default('Operational intelligence from your Nexus control plane.') }}{% endblock %} + ͏‌ ͏‌ ͏‌ ͏‌ ͏‌  +
+ + + + + + + + diff --git a/nexus/communication/email/templates/emails/approval_required.html b/nexus/communication/email/templates/emails/approval_required.html new file mode 100644 index 0000000..3a94812 --- /dev/null +++ b/nexus/communication/email/templates/emails/approval_required.html @@ -0,0 +1,67 @@ +{#- + EMAIL TYPE 6 · Approval Required (one of the most important emails) + Personality: amber attention. Must convey risk clearly and make Approve/Reject + unmistakable. Governance flags come from the trusted server-side table. + + Context: + eyebrow="Approval Required", subject, subtitle, timestamp, version + request = {requester, task_title, task_description, runtime, repository, + reason, risk, risk_tone, files:[str], approve_url, reject_url, audit_id, expires} +-#} +{% extends "base.html" %} +{% block accent %}#D97706{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/table.html" as tbl %} + +{% call sec.panel("warning", title="Authorization needed before execution") %} + {{ request.task_title | default("A governed task is blocked awaiting your decision.") }} +{% endcall %} + +{{ rule.spacer(18) }} +{% call sec.section("Risk assessment", eyebrow="Governance") %} + {{ chip.status_chip((request.risk | default("MEDIUM")) ~ " RISK", request.risk_tone | default("warning")) }} +{% endcall %} + +{{ rule.spacer(16) }} +{{ tbl.kv_grid([ + ["Requester", request.requester | default("system")], + ["Runtime", request.runtime | default("nexus")], + ["Repository", request.repository | default("workspace_root")], + ["Audit ID", request.audit_id | default("—")] +]) }} + +{% if request.reason %} +{{ rule.spacer(16) }} +{% call sec.section("Reason", eyebrow="Why") %}{{ request.reason }}{% endcall %} +{% endif %} + +{% if request.task_description %} +{{ rule.spacer(16) }} +{% call sec.section("Task detail", eyebrow="Scope") %}{{ request.task_description }}{% endcall %} +{% endif %} + +{% if request.files %} +{{ rule.spacer(16) }} +{% call sec.section("Files in scope", eyebrow="Impact") %}{% endcall %} +{{ rule.spacer(10) }} +{{ sec.code_block(request.files | join("\n"), label=(request.files | length | string) ~ " path(s)") }} +{% endif %} + +{{ rule.divider() }} +
+ {{ btn.button_row([ + {"label":"✓ Approve","href": request.approve_url | default("#"), "variant":"success"}, + {"label":"✕ Reject","href": request.reject_url | default("#"), "variant":"danger-outline"} + ]) }} +
+ +{% if request.expires %} +{{ rule.spacer(14) }} +

This request expires {{ request.expires }}. No action means it is denied (fail-closed).

+{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/conversations/action_items.html b/nexus/communication/email/templates/emails/conversations/action_items.html new file mode 100644 index 0000000..3f570c3 --- /dev/null +++ b/nexus/communication/email/templates/emails/conversations/action_items.html @@ -0,0 +1,38 @@ +{#- + Q&A FAMILY · Action Items (also the base for: Follow-ups) + Personality: warning amber — these are things that need doing. + + Context: + eyebrow="Action Items", subject, subtitle, timestamp, version + items = [{text, owner, due, priority, priority_tone, done, href}] + summary_line (optional) +-#} +{% extends "base.html" %} +{% block accent %}#D97706{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} + +{% if summary_line %}{% call sec.panel("warning") %}{{ summary_line }}{% endcall %}{{ rule.spacer(16) }}{% endif %} + + +{% for a in items %} + +{% endfor %} +
+ + + + {% if a.href %}{% endif %} + +
+{% endblock %} diff --git a/nexus/communication/email/templates/emails/conversations/conversation_summary.html b/nexus/communication/email/templates/emails/conversations/conversation_summary.html new file mode 100644 index 0000000..da00c9e --- /dev/null +++ b/nexus/communication/email/templates/emails/conversations/conversation_summary.html @@ -0,0 +1,84 @@ +{#- + Q&A FAMILY · Conversation Summary (also the base for: Meeting Notes, + AI Session Recap, Knowledge Capture, Daily Conversation Digest, Research + Discussion, Operator Notes — see EMAIL_COMPONENT_LIBRARY.md for the mapping). + Personality: communication indigo. + + Context: + eyebrow="Conversation Summary", subject, subtitle, timestamp, version + convo = {tldr, topics:[str], key_points:[str], decisions:[str], + action_items:[{text, owner, due, done}], follow_ups:[str], + references:[{label,href}]} +-#} +{% extends "base.html" %} +{% block accent %}#4F46E5{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/divider.html" as rule %} + +{% if convo.tldr %} +{% call sec.panel("info", title="TL;DR") %}{{ convo.tldr }}{% endcall %} +{{ rule.spacer(16) }} +{% endif %} + +{% if convo.topics %} +
+ {% for t in convo.topics %}{{ bdg.badge(t, "brand") }} {% endfor %} +
+{{ rule.spacer(16) }} +{% endif %} + +{% if convo.key_points %} +{% call sec.section("Key points", eyebrow="Discussion") %} + + {% for k in convo.key_points %}{% endfor %} +
{{ k }}
+{% endcall %} +{% endif %} + +{% if convo.decisions %} +{{ rule.divider() }} +{% call sec.section("Decisions", eyebrow="Agreed", accent="#16A34A") %} + + {% for d in convo.decisions %}{% endfor %} +
{{ d }}
+{% endcall %} +{% endif %} + +{% if convo.action_items %} +{{ rule.divider() }} +{% call sec.section("Action items", eyebrow="Owned", accent="#D97706") %}{% endcall %} +{{ rule.spacer(10) }} + +{% for a in convo.action_items %} + + + + +{% endfor %} +
{% if a.done %}☑{% else %}☐{% endif %} + {{ a.text }} +
+ {% if a.owner %}{{ bdg.badge("@" ~ a.owner, "neutral") }}{% endif %} + {% if a.due %} {{ bdg.badge("due " ~ a.due, "warning") }}{% endif %} +
+
+{% endif %} + +{% if convo.follow_ups %} +{{ rule.divider() }} +{% call sec.section("Follow-ups", eyebrow="Later") %} + + {% for f in convo.follow_ups %}{% endfor %} +
{{ f }}
+{% endcall %} +{% endif %} + +{% if convo.references %} +{{ rule.divider() }} +{% call sec.section("References", eyebrow="Linked") %} + {% for r in convo.references %}↗ {{ r.label }}
{% endfor %} +{% endcall %} +{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/conversations/decision_summary.html b/nexus/communication/email/templates/emails/conversations/decision_summary.html new file mode 100644 index 0000000..02e3d30 --- /dev/null +++ b/nexus/communication/email/templates/emails/conversations/decision_summary.html @@ -0,0 +1,34 @@ +{#- + Q&A FAMILY · Decision Summary + Personality: success green — decisions are settled outcomes. + + Context: + eyebrow="Decision Summary", subject, subtitle, timestamp, version + decisions = [{title, decision, rationale, alternatives:[str], owner, date, status, status_tone}] +-#} +{% extends "base.html" %} +{% block accent %}#16A34A{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/table.html" as tbl %} + +{% for d in decisions %} +{% call sec.section(d.title, eyebrow="Decision " ~ loop.index|string, accent="#16A34A") %} + {{ chip.status_chip(d.status | default("Decided"), d.status_tone | default("success")) }} + {% if d.owner %} {{ bdg.badge("@" ~ d.owner, "neutral") }}{% endif %} + {% if d.date %} {{ bdg.badge(d.date, "neutral") }}{% endif %} +
Decision: {{ d.decision }}
+ {% if d.rationale %}
Rationale: {{ d.rationale }}
{% endif %} +{% endcall %} +{% if d.alternatives %} +{{ rule.spacer(10) }} +{% call sec.panel("neutral", title="Alternatives considered") %} + {% for alt in d.alternatives %}• {{ alt }}
{% endfor %} +{% endcall %} +{% endif %} +{% if not loop.last %}{{ rule.divider() }}{% endif %} +{% endfor %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/conversations/qa_transcript.html b/nexus/communication/email/templates/emails/conversations/qa_transcript.html new file mode 100644 index 0000000..f6f546f --- /dev/null +++ b/nexus/communication/email/templates/emails/conversations/qa_transcript.html @@ -0,0 +1,51 @@ +{#- + Q&A FAMILY · Q&A Transcript + Personality: communication indigo. A faithful, readable record of a Dex session. + + Context: + eyebrow="Q&A Transcript", subject, subtitle, timestamp, version + transcript = {participants:[str], turns:[{role, name, time, text}]} + role: "operator" (right/indigo) | "dex" (left/surface) | "system" +-#} +{% extends "base.html" %} +{% block accent %}#4F46E5{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/divider.html" as rule %} + +{% if transcript.participants %} +
+ {% for p in transcript.participants %}{{ bdg.badge(p, "neutral") }} {% endfor %} +
+{{ rule.spacer(16) }} +{% endif %} + +{% for turn in transcript.turns %} +{% if turn.role == "operator" %} + + + +
  +
{{ turn.name | default("You") }}{% if turn.time %} · {{ turn.time }}{% endif %}
+
+ {{ turn.text }} +
+
+{% elif turn.role == "system" %} +
+ {{ turn.text }} +
+{% else %} + + + +
+
{{ turn.name | default("Dex") }}{% if turn.time %} · {{ turn.time }}{% endif %}
+
+ {{ turn.text }} +
+
 
+{% endif %} +{% endfor %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/execution_completed.html b/nexus/communication/email/templates/emails/execution_completed.html new file mode 100644 index 0000000..2c1284b --- /dev/null +++ b/nexus/communication/email/templates/emails/execution_completed.html @@ -0,0 +1,71 @@ +{#- + EMAIL TYPE 7 · Execution Completed + Personality: success green. Reassuring, evidence-backed. + + Context: + eyebrow="Execution Completed", subject, subtitle, timestamp, version + run = {task_title, task_id, runtime, duration, summary, + metrics:[{label,value,delta,trend}], artifacts:[{name,size,href}], + outputs:[str], logs_url, audit_id} +-#} +{% extends "base.html" %} +{% block accent %}#16A34A{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/metric_card.html" as mc %} +{% import "partials/table.html" as tbl %} + +{% call sec.section("Result", eyebrow="Execution") %} + {{ chip.status_chip("Completed", "success") }} +
{{ run.summary | default("The task completed successfully.") }}
+{% endcall %} + +{{ rule.spacer(16) }} +{{ tbl.kv_grid([ + ["Task ID", run.task_id | default("—")], + ["Runtime", run.runtime | default("nexus")], + ["Duration", run.duration | default("—")], + ["Audit ID", run.audit_id | default("—")] +]) }} + +{% if run.metrics %} +{{ rule.divider() }} +{% call sec.section("Metrics", eyebrow="Performance") %}{% endcall %} +{{ rule.spacer(12) }} +{{ mc.metric_row(run.metrics) }} +{% endif %} + +{% if run.artifacts %} +{{ rule.divider() }} +{% call sec.section("Artifacts", eyebrow="Outputs", accent="#6366F1") %}{% endcall %} +{{ rule.spacer(10) }} +{% for a in run.artifacts %} + + + +{% if not loop.last %}{{ rule.spacer(8) }}{% endif %} +{% endfor %} +{% endif %} + +{% if run.outputs %} +{{ rule.divider() }} +{% call sec.section("Output", eyebrow="Result") %}{% endcall %} +{{ rule.spacer(10) }} +{{ sec.code_block(run.outputs | join("\n"), label="stdout") }} +{% endif %} + +{{ rule.divider() }} +
+ {{ btn.button("View full logs", run.logs_url | default("#"), "ghost") }} +
+{% endblock %} diff --git a/nexus/communication/email/templates/emails/execution_failed.html b/nexus/communication/email/templates/emails/execution_failed.html new file mode 100644 index 0000000..b32f97b --- /dev/null +++ b/nexus/communication/email/templates/emails/execution_failed.html @@ -0,0 +1,75 @@ +{#- + EMAIL TYPE 8 · Execution Failed (premium incident report) + Personality: danger red, calm and forensic — not panicky. + + Context: + eyebrow="Execution Failed", subject, subtitle, timestamp, version + incident = {task_title, task_id, runtime, stage, duration, exit_status, + stages:[{time,title,tone,body}], stderr, stack, root_cause, + recovery:[str], retry_url, logs_url, audit_id} +-#} +{% extends "base.html" %} +{% block accent %}#DC2626{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/timeline.html" as tl %} +{% import "partials/table.html" as tbl %} + +{% call sec.panel("error", title="Execution failed at " ~ (incident.stage | default("an unknown stage"))) %} + {{ incident.task_title | default("A task terminated abnormally.") }} +{% endcall %} + +{{ rule.spacer(16) }} +{{ tbl.kv_grid([ + ["Task ID", incident.task_id | default("—")], + ["Runtime", incident.runtime | default("nexus")], + ["Exit status", incident.exit_status | default("failure")], + ["Duration", incident.duration | default("—")], + ["Audit ID", incident.audit_id | default("—")] +]) }} + +{% if incident.stages %} +{{ rule.divider() }} +{% call sec.section("Failure timeline", eyebrow="Sequence", accent="#DC2626") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tl.timeline(incident.stages) }} +{% endif %} + +{% if incident.root_cause %} +{{ rule.divider() }} +{% call sec.section("Root cause", eyebrow="Analysis", accent="#DC2626") %} + {% call sec.panel("error") %}{{ incident.root_cause }}{% endcall %} +{% endcall %} +{% endif %} + +{% if incident.stderr or incident.stack %} +{{ rule.divider() }} +{% call sec.section("Logs & stack trace", eyebrow="Evidence") %}{% endcall %} +{{ rule.spacer(10) }} +{% if incident.stderr %}{{ sec.code_block(incident.stderr, label="stderr") }}{{ rule.spacer(10) }}{% endif %} +{% if incident.stack %}{{ sec.code_block(incident.stack, label="stack trace") }}{% endif %} +{% endif %} + +{% if incident.recovery %} +{{ rule.divider() }} +{% call sec.section("Suggested recovery", eyebrow="Remediation", accent="#16A34A") %} + + {% for step in incident.recovery %} + + {% endfor %} +
{{ loop.index }}.{{ step }}
+{% endcall %} +{% endif %} + +{{ rule.divider() }} +
+ {{ btn.button_row([ + {"label":"↻ Retry execution","href": incident.retry_url | default("#"), "variant":"danger"}, + {"label":"View full logs","href": incident.logs_url | default("#"), "variant":"ghost"} + ]) }} +
+{% endblock %} diff --git a/nexus/communication/email/templates/emails/monthly_executive.html b/nexus/communication/email/templates/emails/monthly_executive.html new file mode 100644 index 0000000..d1dc84a --- /dev/null +++ b/nexus/communication/email/templates/emails/monthly_executive.html @@ -0,0 +1,66 @@ +{#- + EMAIL TYPE 12 · Monthly Executive Report (CEO style) + Personality: deep navy — the most composed, narrative-led layout. + + Context: + eyebrow="Monthly Executive Report", subject, subtitle, timestamp, version + exec = {highlights:[str], kpis:[{label,value,delta,trend}], + growth:[{label,pct,tone}], reliability:[[k,v]], execution:[[k,v]], + research:[[k,v]], architecture:[str], recommendations:[str], + roadmap:[{time,title,tone,body}]} +-#} +{% extends "base.html" %} +{% block accent %}#0F172A{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/divider.html" as rule %} +{% import "partials/metric_card.html" as mc %} +{% import "partials/timeline.html" as tl %} +{% import "partials/table.html" as tbl %} + +{% if exec.highlights %} +{% call sec.section("Highlights", eyebrow="The month in brief") %} + + {% for h in exec.highlights %}{% endfor %} +
{{ h }}
+{% endcall %} +{% endif %} + +{% if exec.kpis %}{{ rule.spacer(18) }}{{ mc.metric_row(exec.kpis) }}{% endif %} + +{% if exec.growth %} +{{ rule.divider() }} +{% call sec.section("Growth", eyebrow="Trajectory", accent="#16A34A") %}{% endcall %} +{{ rule.spacer(14) }} +{% for g in exec.growth %}{{ tbl.progress(g.pct, g.tone | default("success"), g.label) }}{% if not loop.last %}{{ rule.spacer(14) }}{% endif %}{% endfor %} +{% endif %} + +{% if exec.reliability %}{{ rule.divider() }}{% call sec.section("Reliability", eyebrow="Operations", accent="#16A34A") %}{% endcall %}{{ rule.spacer(12) }}{{ tbl.kv_grid(exec.reliability) }}{% endif %} +{% if exec.execution %}{{ rule.divider() }}{% call sec.section("Execution", eyebrow="Delivery", accent="#6366F1") %}{% endcall %}{{ rule.spacer(12) }}{{ tbl.kv_grid(exec.execution) }}{% endif %} +{% if exec.research %}{{ rule.divider() }}{% call sec.section("Research", eyebrow="Intelligence", accent="#0EA5E9") %}{% endcall %}{{ rule.spacer(12) }}{{ tbl.kv_grid(exec.research) }}{% endif %} + +{% if exec.architecture %} +{{ rule.divider() }} +{% call sec.section("Architecture", eyebrow="Platform") %} + + {% for a in exec.architecture %}{% endfor %} +
{{ a }}
+{% endcall %} +{% endif %} + +{% if exec.recommendations %} +{{ rule.divider() }} +{% call sec.section("Recommendations", eyebrow="Strategy") %} + + {% for rec in exec.recommendations %}{% endfor %} +
{{ rec }}
+{% endcall %} +{% endif %} + +{% if exec.roadmap %} +{{ rule.divider() }} +{% call sec.section("Roadmap", eyebrow="What's next", accent="#7C3AED") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tl.timeline(exec.roadmap) }} +{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/morning_digest.html b/nexus/communication/email/templates/emails/morning_digest.html new file mode 100644 index 0000000..803b2a5 --- /dev/null +++ b/nexus/communication/email/templates/emails/morning_digest.html @@ -0,0 +1,139 @@ +{#- + EMAIL TYPE 1 · Nexus Morning Operational Digest + Personality: brand indigo. The operator's first read of the day — scannable, + confident, action-oriented. + + Context schema (see PLACEHOLDER DATA SCHEMA in EMAIL_DESIGN_SYSTEM.md): + eyebrow="Morning Digest", subject, subtitle, timestamp, version, brand_links + greeting, owner_name, summary_line + health = {label, status, metrics:[{label,value,delta,trend}]} + research = [{title, score, source, url, summary}] + tasks = [[title, status, priority]] + approvals = [{title, requester, href}] + scheduler = [{time,title,tone,body}] + runtime = [{name, status}] status: success|warning|danger|pending|neutral + alerts = [{tone,title,body}] + recommendations = [str] + actions = [{label, href, variant}] +-#} +{% extends "base.html" %} +{% block accent %}#4F46E5{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/metric_card.html" as mc %} +{% import "partials/timeline.html" as tl %} +{% import "partials/table.html" as tbl %} + +{# greeting + executive summary #} +{% call sec.section("Executive summary", eyebrow=(greeting | default("Good morning")) ~ ((", " ~ owner_name) if owner_name else "")) %} + {{ summary_line | default("All core subsystems are nominal. Here is your operational picture for the day.") }} +{% endcall %} + +{% if health and health.metrics %} +{{ rule.spacer(18) }} +{{ mc.metric_row(health.metrics) }} +{% endif %} + +{{ rule.divider() }} + +{# today's health #} +{% call sec.section("Today's health", eyebrow="System", accent="#16A34A") %} + {{ chip.status_chip(health.label | default("Healthy"), health.status | default("success")) }} +{% endcall %} + +{# research highlights #} +{% if research %} +{{ rule.divider() }} +{% call sec.section("Research highlights", eyebrow="Intelligence", accent="#0EA5E9") %}{% endcall %} +{{ rule.spacer(12) }} +{% for r in research %} + + + +{% if not loop.last %}{{ rule.spacer(10) }}{% endif %} +{% endfor %} +{% endif %} + +{# tasks #} +{% if tasks %} +{{ rule.divider() }} +{% call sec.section("Active tasks", eyebrow="Execution", accent="#6366F1") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tbl.data_table(columns=["Task","Status","Priority"], rows=tasks, aligns=["left","left","right"]) }} +{% endif %} + +{# pending approvals #} +{% if approvals %} +{{ rule.divider() }} +{% call sec.section("Pending approvals", eyebrow="Governance", accent="#D97706") %}{% endcall %} +{{ rule.spacer(10) }} +{% for a in approvals %} +{% call sec.panel("warning", title=a.title) %}Requested by {{ a.requester | default("system") }}.{% if a.href %} Review →{% endif %}{% endcall %} +{% if not loop.last %}{{ rule.spacer(8) }}{% endif %} +{% endfor %} +{% endif %} + +{# scheduler #} +{% if scheduler %} +{{ rule.divider() }} +{% call sec.section("Scheduler", eyebrow="Automation", accent="#0891B2") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tl.timeline(scheduler) }} +{% endif %} + +{# runtime status #} +{% if runtime %} +{{ rule.divider() }} +{% call sec.section("Runtime status", eyebrow="Fleet", accent="#D97706") %} + {% for rt in runtime %}{{ chip.status_chip(rt.name, rt.status | default("neutral")) }} {% endfor %} +{% endcall %} +{% endif %} + +{# alerts #} +{% if alerts %} +{{ rule.divider() }} +{% call sec.section("Important alerts", eyebrow="Attention", accent="#DC2626") %}{% endcall %} +{{ rule.spacer(10) }} +{% for al in alerts %} +{% call sec.panel(al.tone | default("warning"), title=al.title) %}{{ al.body }}{% endcall %} +{% if not loop.last %}{{ rule.spacer(8) }}{% endif %} +{% endfor %} +{% endif %} + +{# recommendations #} +{% if recommendations %} +{{ rule.divider() }} +{% call sec.section("Recommendations", eyebrow="Next") %} + + {% for rec in recommendations %} + + + + + {% endfor %} +
{{ rec }}
+{% endcall %} +{% endif %} + +{# quick actions #} +{% if actions %} +{{ rule.divider() }} +
+ {{ btn.button_row(actions) }} +
+{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/operational_intelligence.html b/nexus/communication/email/templates/emails/operational_intelligence.html new file mode 100644 index 0000000..5f58f18 --- /dev/null +++ b/nexus/communication/email/templates/emails/operational_intelligence.html @@ -0,0 +1,81 @@ +{#- + EMAIL TYPE 2 · Operational Intelligence Report (executive report) + Personality: deep navy — authoritative, dense but calm. + + Charts: email clients can't run JS/canvas. We render bullet/bar "charts" from + the progress() macro (bulletproof) and reserve a labelled slot where a + pre-rendered PNG would be dropped in (see EMAIL_TEMPLATE_GUIDELINES.md). + + Context: + eyebrow="Operational Intelligence", subject, subtitle, timestamp, version + report = {summary, metrics:[{label,value,delta,trend}], + architecture:[[key,value]], performance:[{label,pct,tone}], + failures:[[task,runtime,status]], recovery:[{time,title,tone,body}], + recommendations:[str], appendix:[[key,value]]} +-#} +{% extends "base.html" %} +{% block accent %}#0F172A{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/divider.html" as rule %} +{% import "partials/metric_card.html" as mc %} +{% import "partials/timeline.html" as tl %} +{% import "partials/table.html" as tbl %} + +{% call sec.section("Executive summary", eyebrow="Overview") %}{{ report.summary | default("Operational posture is stable across the reporting window.") }}{% endcall %} + +{% if report.metrics %} +{{ rule.spacer(18) }} +{{ mc.metric_row(report.metrics) }} +{% endif %} + +{% if report.performance %} +{{ rule.divider() }} +{% call sec.section("Performance", eyebrow="Throughput & latency") %}{% endcall %} +{{ rule.spacer(14) }} +{% for p in report.performance %} +{{ tbl.progress(p.pct, p.tone | default("brand"), p.label) }} +{% if not loop.last %}{{ rule.spacer(14) }}{% endif %} +{% endfor %} +
Bar view · a full time-series chart is attached as a rendered image in the executive PDF.
+{% endif %} + +{% if report.architecture %} +{{ rule.divider() }} +{% call sec.section("Architecture", eyebrow="Topology") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tbl.kv_grid(report.architecture) }} +{% endif %} + +{% if report.failures %} +{{ rule.divider() }} +{% call sec.section("Failures", eyebrow="Reliability", accent="#DC2626") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tbl.data_table(columns=["Task","Runtime","Status"], rows=report.failures, aligns=["left","left","right"]) }} +{% endif %} + +{% if report.recovery %} +{{ rule.divider() }} +{% call sec.section("Recovery", eyebrow="Self-healing", accent="#16A34A") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tl.timeline(report.recovery) }} +{% endif %} + +{% if report.recommendations %} +{{ rule.divider() }} +{% call sec.section("Recommendations", eyebrow="Action") %} + + {% for rec in report.recommendations %} + + {% endfor %} +
{{ rec }}
+{% endcall %} +{% endif %} + +{% if report.appendix %} +{{ rule.divider() }} +{% call sec.section("Appendix", eyebrow="Reference") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tbl.kv_grid(report.appendix) }} +{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/reminder.html b/nexus/communication/email/templates/emails/reminder.html new file mode 100644 index 0000000..2ad4faa --- /dev/null +++ b/nexus/communication/email/templates/emails/reminder.html @@ -0,0 +1,39 @@ +{#- + EMAIL TYPE 5 · Reminder (simple, beautiful, minimal) + Personality: pending purple. Calm and singular — one thing to remember. + + Context: + eyebrow="Reminder", subject, subtitle, timestamp, version + reminder = {time, reason, context, suggested_action, cta_label, cta_url} +-#} +{% extends "base.html" %} +{% block accent %}#7C3AED{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/status_chip.html" as chip %} + +{% if reminder.time %} +
+ {{ chip.status_chip("⏰ " ~ reminder.time, "pending") }} +
+{{ rule.spacer(16) }} +{% endif %} + +{% call sec.section(reminder.reason | default("You asked to be reminded."), eyebrow="Reminder", accent="#7C3AED") %} + {% if reminder.context %}{{ reminder.context }}{% endif %} +{% endcall %} + +{% if reminder.suggested_action %} +{{ rule.spacer(16) }} +{% call sec.panel("info", title="Suggested action") %}{{ reminder.suggested_action }}{% endcall %} +{% endif %} + +{% if reminder.cta_url %} +{{ rule.spacer(20) }} +
+ {{ btn.button(reminder.cta_label | default("Mark as done"), reminder.cta_url, "primary") }} +
+{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/research_report.html b/nexus/communication/email/templates/emails/research_report.html new file mode 100644 index 0000000..27039fa --- /dev/null +++ b/nexus/communication/email/templates/emails/research_report.html @@ -0,0 +1,68 @@ +{#- + EMAIL TYPE 3 · Research Intelligence Report + Personality: research sky-blue. Reads like a sharp analyst briefing. + + Context: + eyebrow="Research Intelligence", subject, subtitle, timestamp, version + brief = {headline, importance, importance_tone, summary, + insights:[str], sources:[{title,source,url,score}], + recommendations:[str], actions:[{label,href,variant}], tags:[str]} +-#} +{% extends "base.html" %} +{% block accent %}#0EA5E9{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/table.html" as tbl %} + +{% if brief.headline %} +{% call sec.section(brief.headline, eyebrow="Headline", accent="#0EA5E9") %} + {{ chip.status_chip((brief.importance | default("NOTABLE")), brief.importance_tone | default("info")) }} + {% if brief.tags %} {% for t in brief.tags %}{{ bdg.badge(t, "neutral") }} {% endfor %}{% endif %} +{% endcall %} +{% endif %} + +{% if brief.summary %} +{{ rule.spacer(16) }} +{% call sec.section("Summary", eyebrow="TL;DR") %}{{ brief.summary }}{% endcall %} +{% endif %} + +{% if brief.insights %} +{{ rule.divider() }} +{% call sec.section("Key insights", eyebrow="Analysis", accent="#0EA5E9") %} + + {% for ins in brief.insights %} + + {% endfor %} +
{{ ins }}
+{% endcall %} +{% endif %} + +{% if brief.sources %} +{{ rule.divider() }} +{% call sec.section("Sources", eyebrow="Provenance") %}{% endcall %} +{{ rule.spacer(12) }} +{% set ns = namespace(rows=[]) %} +{% for s in brief.sources %}{% set ns.rows = ns.rows + [[s.title, s.source | default('—'), '★ ' ~ (s.score | default('—')) ~ '/5']] %}{% endfor %} +{{ tbl.data_table(columns=["Title","Source","Score"], rows=ns.rows, aligns=["left","left","right"]) }} +{% endif %} + +{% if brief.recommendations %} +{{ rule.divider() }} +{% call sec.section("Recommendations", eyebrow="So what") %} + + {% for rec in brief.recommendations %} + + {% endfor %} +
{{ rec }}
+{% endcall %} +{% endif %} + +{% if brief.actions %} +{{ rule.divider() }} +
{{ btn.button_row(brief.actions) }}
+{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/scheduler_report.html b/nexus/communication/email/templates/emails/scheduler_report.html new file mode 100644 index 0000000..e4332e1 --- /dev/null +++ b/nexus/communication/email/templates/emails/scheduler_report.html @@ -0,0 +1,43 @@ +{#- + EMAIL TYPE 10 · Scheduler Report (daily job execution summary) + Personality: scheduler teal. + + Context: + eyebrow="Scheduler Report", subject, subtitle, timestamp, version + scheduler = {health_label, health_status, + metrics:[{label,value,delta,trend}], + jobs:[[name, status, duration]], events:[{time,title,tone,body}]} +-#} +{% extends "base.html" %} +{% block accent %}#0891B2{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/metric_card.html" as mc %} +{% import "partials/timeline.html" as tl %} +{% import "partials/table.html" as tbl %} + +{% call sec.section("Scheduler health", eyebrow="Automation", accent="#0891B2") %} + {{ chip.status_chip(scheduler.health_label | default("All jobs nominal"), scheduler.health_status | default("success")) }} +{% endcall %} + +{% if scheduler.metrics %} +{{ rule.spacer(18) }} +{{ mc.metric_row(scheduler.metrics) }} +{% endif %} + +{% if scheduler.jobs %} +{{ rule.divider() }} +{% call sec.section("Job runs", eyebrow="Last 24 hours") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tbl.data_table(columns=["Job","Status","Duration"], rows=scheduler.jobs, aligns=["left","left","right"]) }} +{% endif %} + +{% if scheduler.events %} +{{ rule.divider() }} +{% call sec.section("Execution timeline", eyebrow="Sequence", accent="#0891B2") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tl.timeline(scheduler.events) }} +{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/security_alert.html b/nexus/communication/email/templates/emails/security_alert.html new file mode 100644 index 0000000..225505f --- /dev/null +++ b/nexus/communication/email/templates/emails/security_alert.html @@ -0,0 +1,62 @@ +{#- + EMAIL TYPE 9 · Security Alert (governance / sandbox / policy violation) + Personality: danger red. Authoritative, precise, action-first. + + Context: + eyebrow="Security Alert", subject, subtitle, timestamp, version + alert = {severity, severity_tone, category, summary, + details:[[key,value]], evidence, operator_actions:[str], + ack_url, audit_id} +-#} +{% extends "base.html" %} +{% block accent %}#DC2626{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/status_chip.html" as chip %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/table.html" as tbl %} + +{% call sec.panel("error", title=(alert.category | default("Policy violation")) ~ " detected") %} + {{ alert.summary | default("A governance control was triggered. Review the detail below.") }} +{% endcall %} + +{{ rule.spacer(16) }} +
+ {{ chip.status_chip((alert.severity | default("HIGH")) ~ " SEVERITY", alert.severity_tone | default("danger")) }} +  {{ bdg.badge(alert.category | default("governance"), "danger") }} +
+ +{% if alert.details %} +{{ rule.spacer(16) }} +{{ tbl.kv_grid(alert.details) }} +{% endif %} + +{% if alert.evidence %} +{{ rule.divider() }} +{% call sec.section("Evidence", eyebrow="Audit") %}{% endcall %} +{{ rule.spacer(10) }} +{{ sec.code_block(alert.evidence, label="event") }} +{% endif %} + +{% if alert.operator_actions %} +{{ rule.divider() }} +{% call sec.section("Required operator action", eyebrow="Respond", accent="#DC2626") %} + + {% for act in alert.operator_actions %} + + {% endfor %} +
{{ loop.index }}.{{ act }}
+{% endcall %} +{% endif %} + +{{ rule.divider() }} +
+ {{ btn.button("Acknowledge & review", alert.ack_url | default("#"), "danger") }} +
+{% if alert.audit_id %} +{{ rule.spacer(12) }} +

Audit ID · {{ alert.audit_id }}

+{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/todo_digest.html b/nexus/communication/email/templates/emails/todo_digest.html new file mode 100644 index 0000000..1896d5d --- /dev/null +++ b/nexus/communication/email/templates/emails/todo_digest.html @@ -0,0 +1,57 @@ +{#- + EMAIL TYPE 4 · TODO Digest (daily productivity email) + Personality: brand indigo. + + Context: + eyebrow="TODO Digest", subject, subtitle, timestamp, version + todo = {today:[[task,priority,est]], upcoming:[[task,due]], blocked:[{title,reason}], + completed:[str], quick_links:[{label,href,variant}]} +-#} +{% extends "base.html" %} +{% block accent %}#4F46E5{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/badge.html" as bdg %} +{% import "partials/divider.html" as rule %} +{% import "partials/button.html" as btn %} +{% import "partials/table.html" as tbl %} + +{% if todo.today %} +{% call sec.section("Today", eyebrow="Focus", accent="#4F46E5") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tbl.data_table(columns=["Task","Priority","Est."], rows=todo.today, aligns=["left","left","right"]) }} +{% endif %} + +{% if todo.upcoming %} +{{ rule.divider() }} +{% call sec.section("Upcoming", eyebrow="Soon") %}{% endcall %} +{{ rule.spacer(12) }} +{{ tbl.data_table(columns=["Task","Due"], rows=todo.upcoming, aligns=["left","right"]) }} +{% endif %} + +{% if todo.blocked %} +{{ rule.divider() }} +{% call sec.section("Blocked", eyebrow="Attention", accent="#D97706") %}{% endcall %} +{{ rule.spacer(10) }} +{% for b in todo.blocked %} +{% call sec.panel("warning", title=b.title) %}{{ b.reason }}{% endcall %} +{% if not loop.last %}{{ rule.spacer(8) }}{% endif %} +{% endfor %} +{% endif %} + +{% if todo.completed %} +{{ rule.divider() }} +{% call sec.section("Completed", eyebrow="Done", accent="#16A34A") %} + + {% for c in todo.completed %} + + {% endfor %} +
{{ c }}
+{% endcall %} +{% endif %} + +{% if todo.quick_links %} +{{ rule.divider() }} +
{{ btn.button_row(todo.quick_links) }}
+{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/emails/weekly_review.html b/nexus/communication/email/templates/emails/weekly_review.html new file mode 100644 index 0000000..8379623 --- /dev/null +++ b/nexus/communication/email/templates/emails/weekly_review.html @@ -0,0 +1,62 @@ +{#- + EMAIL TYPE 11 · Weekly Operational Review (executive dashboard) + Personality: deep navy. + + Context: + eyebrow="Weekly Operations", subject, subtitle, timestamp, version + review = {kpis:[{label,value,delta,trend}], trends:[{label,pct,tone}], + research_count, tasks:[[k,v]], reliability:[[k,v]], + llm_usage:[[k,v]], cost:[[k,v]], recommendations:[str]} +-#} +{% extends "base.html" %} +{% block accent %}#0F172A{% endblock %} +{% block content %} +{% import "partials/section.html" as sec %} +{% import "partials/divider.html" as rule %} +{% import "partials/metric_card.html" as mc %} +{% import "partials/table.html" as tbl %} + +{% call sec.section("This week at a glance", eyebrow="KPIs") %}{% endcall %} +{% if review.kpis %}{{ rule.spacer(16) }}{{ mc.metric_row(review.kpis) }}{% endif %} + +{% if review.trends %} +{{ rule.divider() }} +{% call sec.section("Trends", eyebrow="Week over week") %}{% endcall %} +{{ rule.spacer(14) }} +{% for t in review.trends %}{{ tbl.progress(t.pct, t.tone | default("brand"), t.label) }}{% if not loop.last %}{{ rule.spacer(14) }}{% endif %}{% endfor %} +
Sparkline view · full trend charts in the attached dashboard export.
+{% endif %} + +{% if review.reliability %} +{{ rule.divider() }} +{% call sec.section("Reliability", eyebrow="Uptime & recovery", accent="#16A34A") %}{% endcall %} +{{ rule.spacer(12) }}{{ tbl.kv_grid(review.reliability) }} +{% endif %} + +{% if review.tasks %} +{{ rule.divider() }} +{% call sec.section("Execution", eyebrow="Tasks", accent="#6366F1") %}{% endcall %} +{{ rule.spacer(12) }}{{ tbl.kv_grid(review.tasks) }} +{% endif %} + +{% if review.llm_usage %} +{{ rule.divider() }} +{% call sec.section("LLM usage", eyebrow="Intelligence", accent="#0EA5E9") %}{% endcall %} +{{ rule.spacer(12) }}{{ tbl.kv_grid(review.llm_usage) }} +{% endif %} + +{% if review.cost %} +{{ rule.divider() }} +{% call sec.section("Cost", eyebrow="Spend", accent="#D97706") %}{% endcall %} +{{ rule.spacer(12) }}{{ tbl.kv_grid(review.cost) }} +{% endif %} + +{% if review.recommendations %} +{{ rule.divider() }} +{% call sec.section("Recommendations", eyebrow="Next week") %} + + {% for rec in review.recommendations %}{% endfor %} +
{{ rec }}
+{% endcall %} +{% endif %} +{% endblock %} diff --git a/nexus/communication/email/templates/partials/badge.html b/nexus/communication/email/templates/partials/badge.html new file mode 100644 index 0000000..ae7faed --- /dev/null +++ b/nexus/communication/email/templates/partials/badge.html @@ -0,0 +1,21 @@ +{#- + Badge — small, static, rounded label for categorisation (subsystem, type, tag). + Import: {% import "partials/badge.html" as bdg %} + Use: {{ bdg.badge("Governance", "brand") }} + {{ bdg.badge("CVE-2026-1234", "danger", mono=True) }} + + tones: neutral · brand · success · warning · danger · info · pending +-#} +{% set _BADGE = { + 'neutral': ('#F1F5F9', '#475569', '#E2E8F0'), + 'brand': ('#EEF2FF', '#4338CA', '#E0E7FF'), + 'success': ('#ECFDF5', '#15803D', '#D1FAE5'), + 'warning': ('#FFFBEB', '#B45309', '#FDE68A'), + 'danger': ('#FEF2F2', '#B91C1C', '#FECACA'), + 'info': ('#EFF6FF', '#1D4ED8', '#DBEAFE'), + 'pending': ('#F5F3FF', '#6D28D9', '#EDE9FE') +} %} +{% macro badge(text, tone='neutral', mono=False) -%} +{%- set c = _BADGE.get(tone, _BADGE['neutral']) -%} +{{ text }} +{%- endmacro %} diff --git a/nexus/communication/email/templates/partials/button.html b/nexus/communication/email/templates/partials/button.html new file mode 100644 index 0000000..b331cda --- /dev/null +++ b/nexus/communication/email/templates/partials/button.html @@ -0,0 +1,51 @@ +{#- + Button — bulletproof CTA. VML fallback gives Outlook real rounded buttons; + every other client uses the padded anchor. Stacks full-width on mobile. + Import: {% import "partials/button.html" as btn %} + Use: {{ btn.button("Open dashboard", "https://...", "primary") }} + {{ btn.button("Approve", "https://...", "success") }} + {{ btn.button("Reject", "https://...", "danger-outline") }} + {{ btn.button_row([{"label":"Approve","href":"#","variant":"success"}, + {"label":"Reject","href":"#","variant":"danger-outline"}]) }} + + variants: primary · neutral · success · danger · danger-outline · ghost +-#} +{% set _BTN = { + 'primary': ('#4F46E5', '#FFFFFF', '#4F46E5'), + 'neutral': ('#0F172A', '#FFFFFF', '#0F172A'), + 'success': ('#16A34A', '#FFFFFF', '#16A34A'), + 'danger': ('#DC2626', '#FFFFFF', '#DC2626'), + 'danger-outline': ('#FFFFFF', '#B91C1C', '#FCA5A5'), + 'ghost': ('#FFFFFF', '#334155', '#CBD5E1') +} %} +{% macro button(label, href, variant='primary', full=False) -%} +{%- set c = _BTN.get(variant, _BTN['primary']) -%} + + + + + +{%- endmacro %} + +{#- horizontal button group; stacks on mobile via .nx-stack -#} +{% macro button_row(buttons) -%} + + + {% for b in buttons %} + + {% endfor %} + +
+ {{ button(b.label, b.href, b.get('variant','primary')) }} +
+{%- endmacro %} diff --git a/nexus/communication/email/templates/partials/divider.html b/nexus/communication/email/templates/partials/divider.html new file mode 100644 index 0000000..9f792d2 --- /dev/null +++ b/nexus/communication/email/templates/partials/divider.html @@ -0,0 +1,19 @@ +{#- + Divider — a hairline rule with configurable vertical breathing room, plus a + pure-space spacer. Keep section rhythm consistent (use the spacing scale). + Import: {% import "partials/divider.html" as rule %} + Use: {{ rule.divider() }} full hairline, 20px above/below + {{ rule.divider(space=12) }} + {{ rule.spacer(24) }} invisible vertical gap only +-#} +{% macro divider(space=20) -%} + + + + + +{%- endmacro %} + +{% macro spacer(space=16) -%} +
 
+{%- endmacro %} diff --git a/nexus/communication/email/templates/partials/footer.html b/nexus/communication/email/templates/partials/footer.html new file mode 100644 index 0000000..b1bd32b --- /dev/null +++ b/nexus/communication/email/templates/partials/footer.html @@ -0,0 +1,68 @@ +{#- + Footer partial — brand reassurance, links, version, legal/automation note. + Emits rows at the container level (sibling of the card row in base.html). + + Context (all optional): + brand_links list of {label, href} + version e.g. "v1.2.0" + footer_note override for the automation/why-am-I-getting-this line + timestamp reused for the generated-at line +-#} + +   + + + + + {# links row #} + {% if brand_links %} + + + + {% endif %} + + {# brand line #} + + + + + {# automation note #} + + + + + {% if timestamp %} + + + + {% endif %} +
+ {% for link in brand_links %}{{ link.label }}{% if not loop.last %}·{% endif %}{% endfor %} +
+ + + + + + {% if version %} + + + {% endif %} + +
+ Nexus + · + AI Orchestration Control Plane + · + {{ version }} +
+
+ {{ footer_note | default('Automated message generated by your Nexus control plane. Replies are not monitored.') }} +
+ Generated {{ timestamp }} +
+ + + +   + diff --git a/nexus/communication/email/templates/partials/header.html b/nexus/communication/email/templates/partials/header.html new file mode 100644 index 0000000..82d7ac5 --- /dev/null +++ b/nexus/communication/email/templates/partials/header.html @@ -0,0 +1,75 @@ +{#- + Header partial — brand lockup, email-type eyebrow, timestamp, title, subtitle. + Emits rows; included by base.html inside the card's inner table. + + Context (all optional, degrade gracefully): + eyebrow short uppercase label, e.g. "Morning Digest" + subject main title line + subtitle one-line supporting sentence + timestamp pre-formatted string, e.g. "Jun 26, 2026 · 08:00 IST" + accent hex used for the eyebrow chip text (defaults to brand indigo) + + Logo: a bulletproof monogram (rounded navy tile + "N"). No external assets, + no SVG — renders identically in Gmail, Outlook, Apple Mail, dark mode. +-#} + + + + + {# brand lockup #} + + {# timestamp #} + + +
+ + + + + +
+ + + + +
N
+
+ Nexus +
+
+ {{ timestamp | default('') }} +
+ + + +{% if eyebrow %} + + + {{ eyebrow }} + + +{% endif %} + +{% if subject %} + + +

{{ subject }}

+ + +{% endif %} + +{% if subtitle %} + + +

{{ subtitle }}

+ + +{% endif %} + + + + + + + + diff --git a/nexus/communication/email/templates/partials/metric_card.html b/nexus/communication/email/templates/partials/metric_card.html new file mode 100644 index 0000000..196d91d --- /dev/null +++ b/nexus/communication/email/templates/partials/metric_card.html @@ -0,0 +1,50 @@ +{#- + Metric card — a single KPI (label, value, optional delta + trend), and a + responsive row of up to three that stacks vertically on mobile. + Import: {% import "partials/metric_card.html" as mc %} + + Single: + {{ mc.metric_card("Uptime", "99.98%", delta="+0.04", trend="up") }} + Row (preferred — handles layout + stacking): + {{ mc.metric_row([ + {"label":"Tasks","value":"24","delta":"+6","trend":"up"}, + {"label":"Approvals","value":"3","trend":"flat"}, + {"label":"Failures","value":"1","delta":"-2","trend":"down","accent":"#DC2626"} + ]) }} + + trend: up (green) · down (red) · flat (muted) · up-bad / down-good to invert semantics +-#} +{% set _TREND = { + 'up': ('#15803D', '▲'), + 'down': ('#B91C1C', '▼'), + 'flat': ('#64748B', '→'), + 'up-bad': ('#B91C1C', '▲'), + 'down-good':('#15803D', '▼') +} %} +{% macro metric_card(label, value, delta=None, trend='flat', accent='#0F172A') -%} +{%- set t = _TREND.get(trend, _TREND['flat']) -%} + + + + + +{%- endmacro %} + +{% macro metric_row(items) -%} + + + {% for it in items %} + + {% if not loop.last %}{% endif %} + {% endfor %} + +
+ {{ metric_card(it.label, it.value, it.get('delta'), it.get('trend','flat'), it.get('accent','#0F172A')) }} +  
+{%- endmacro %} diff --git a/nexus/communication/email/templates/partials/section.html b/nexus/communication/email/templates/partials/section.html new file mode 100644 index 0000000..62f5de7 --- /dev/null +++ b/nexus/communication/email/templates/partials/section.html @@ -0,0 +1,69 @@ +{#- + Section — a titled content block with an optional accent tick and eyebrow, + and a set of typed panels (info / success / warning / error). Sections create + the vertical rhythm of every email. + Import: {% import "partials/section.html" as sec %} + + Titled block (call form): + {% call sec.section("Today's health", eyebrow="System", accent="#16A34A") %} + ...inner html... + {% endcall %} + + Panels (call form): + {% call sec.panel("warning", title="Degraded throughput") %}...{% endcall %} + Tones: info · success · warning · error · neutral +-#} +{% macro section(title, eyebrow=None, accent='#4F46E5') -%} + + + + + + + +
+ {% if eyebrow %}
{{ eyebrow }}
{% endif %} + + + +
+
 
+
+

{{ title }}

+
+
+ {{ caller() }} +
+{%- endmacro %} + +{% set _PANEL = { + 'info': ('#EFF6FF', '#BFDBFE', '#1D4ED8', 'ℹ'), + 'success': ('#ECFDF5', '#A7F3D0', '#15803D', '✓'), + 'warning': ('#FFFBEB', '#FDE68A', '#B45309', '▲'), + 'error': ('#FEF2F2', '#FECACA', '#B91C1C', '✕'), + 'neutral': ('#F8FAFC', '#E2E8F0', '#475569', '•') +} %} +{% macro panel(tone='info', title=None) -%} +{%- set c = _PANEL.get(tone, _PANEL['info']) -%} + + + + +
+ {% if title %} + + + +
{{ c[3] }}{{ title }}
+ {% endif %} +
{{ caller() }}
+
+{%- endmacro %} + +{#- Code / artifact block — monospace, scroll-safe, dark-on-light surface -#} +{% macro code_block(content, label=None) -%} + + {% if label %}{% endif %} + +
{{ label }}
{{ content }}
+{%- endmacro %} diff --git a/nexus/communication/email/templates/partials/status_chip.html b/nexus/communication/email/templates/partials/status_chip.html new file mode 100644 index 0000000..85f2c6b --- /dev/null +++ b/nexus/communication/email/templates/partials/status_chip.html @@ -0,0 +1,35 @@ +{#- + Status chip — a state with a leading status dot. Use for liveness / task / job state. + Import: {% import "partials/status_chip.html" as chip %} + Use: {{ chip.status_chip("Healthy", "success") }} + {{ chip.status_chip("Awaiting approval", "pending") }} + + status: success · warning · danger · info · pending · neutral +-#} +{% set _CHIP = { + 'success': ('#ECFDF5', '#15803D', '#16A34A'), + 'warning': ('#FFFBEB', '#B45309', '#D97706'), + 'danger': ('#FEF2F2', '#B91C1C', '#DC2626'), + 'info': ('#EFF6FF', '#1D4ED8', '#2563EB'), + 'pending': ('#F5F3FF', '#6D28D9', '#7C3AED'), + 'neutral': ('#F1F5F9', '#475569', '#94A3B8') +} %} +{% macro status_chip(text, status='info') -%} +{%- set c = _CHIP.get(status, _CHIP['info']) -%} + + + + +
+ + + +
+ + +
 
+
+ {{ text }} +
+
+{%- endmacro %} diff --git a/nexus/communication/email/templates/partials/table.html b/nexus/communication/email/templates/partials/table.html new file mode 100644 index 0000000..41b18f8 --- /dev/null +++ b/nexus/communication/email/templates/partials/table.html @@ -0,0 +1,64 @@ +{#- + Table + key/value grid — structured data that stays readable on mobile. + Import: {% import "partials/table.html" as tbl %} + + Data table: + {{ tbl.data_table( + columns=["Job", "Status", "Duration"], + rows=[ + ["research_collection", "Succeeded", "12.4s"], + ["daily_briefing", "Succeeded", "3.1s"], + ["approval_sweep", "Skipped", "—"] + ], + aligns=["left","left","right"] + ) }} + + Key/value grid (definition list style for metadata blocks): + {{ tbl.kv_grid([ + ["Requester", "scheduler"], + ["Runtime", "nexus"], + ["Repository", "workspace_root"] + ]) }} +-#} +{% macro data_table(columns, rows, aligns=None) -%} +{%- set al = aligns or [] -%} + + + {% for col in columns %} + + {% endfor %} + + {% for row in rows %} + {%- set rowloop = loop -%} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +{%- endmacro %} + +{% macro kv_grid(pairs) -%} + + {% for p in pairs %} + + + + + {% endfor %} + +{%- endmacro %} + +{#- Progress bar — completion / budget consumption -#} +{% macro progress(pct, tone='brand', label=None) -%} +{%- set _P = {'brand':'#4F46E5','success':'#16A34A','warning':'#D97706','danger':'#DC2626','info':'#2563EB'} -%} +{%- set fill = _P.get(tone, _P['brand']) -%} +{%- set w = [[pct, 0]|max, 100]|min -%} +{% if label %}
{{ label }} {{ w }}%
{% endif %} + + +
+
 
+
+{%- endmacro %} diff --git a/nexus/communication/email/templates/partials/timeline.html b/nexus/communication/email/templates/partials/timeline.html new file mode 100644 index 0000000..793f468 --- /dev/null +++ b/nexus/communication/email/templates/partials/timeline.html @@ -0,0 +1,42 @@ +{#- + Timeline — vertical sequence of events (scheduler runs, execution stages, + audit trail, conversation turns). Each node has a coloured dot, a time, a + title, and optional body. + Import: {% import "partials/timeline.html" as tl %} + Use: + {{ tl.timeline([ + {"time":"08:00","title":"Briefing dispatched","tone":"success","body":"3 channels"}, + {"time":"10:00","title":"Research run","tone":"info","body":"20 findings"}, + {"time":"10:02","title":"Priority feed pushed","tone":"pending"} + ]) }} + + tone: success · warning · danger · info · pending · neutral +-#} +{% set _DOT = { + 'success':'#16A34A', 'warning':'#D97706', 'danger':'#DC2626', + 'info':'#2563EB', 'pending':'#7C3AED', 'neutral':'#94A3B8' +} %} +{% macro timeline(events) -%} + + {% for e in events %} + {%- set dot = _DOT.get(e.get('tone','neutral'), _DOT['neutral']) -%} + + {# rail #} + + {# content #} + + + {% endfor %} +
+ + +
 
+ {% if not loop.last %} +
 
+ {% endif %} +
+ {% if e.time %}
{{ e.time }}
{% endif %} +
{{ e.title }}
+ {% if e.body %}
{{ e.body }}
{% endif %} +
+{%- endmacro %} diff --git a/nexus/communication/email/templates/previews/approval_required.preview.html b/nexus/communication/email/templates/previews/approval_required.preview.html new file mode 100644 index 0000000..0db866b --- /dev/null +++ b/nexus/communication/email/templates/previews/approval_required.preview.html @@ -0,0 +1,436 @@ + + + + + + + + + + Approval Required — Deploy v1.2.0 + + + + + +
+ A governed task is blocked awaiting your decision. + ͏‌ ͏‌ ͏‌ ͏‌ ͏‌  +
+ + + + + + + + \ No newline at end of file diff --git a/nexus/communication/email/templates/previews/execution_failed.preview.html b/nexus/communication/email/templates/previews/execution_failed.preview.html new file mode 100644 index 0000000..916c8dd --- /dev/null +++ b/nexus/communication/email/templates/previews/execution_failed.preview.html @@ -0,0 +1,515 @@ + + + + + + + + + + Execution Failed — compile stage + + + + + +
+ A task terminated abnormally. + ͏‌ ͏‌ ͏‌ ͏‌ ͏‌  +
+ + + + + + + + \ No newline at end of file diff --git a/nexus/communication/email/templates/previews/morning_digest.preview.html b/nexus/communication/email/templates/previews/morning_digest.preview.html new file mode 100644 index 0000000..3b0f22e --- /dev/null +++ b/nexus/communication/email/templates/previews/morning_digest.preview.html @@ -0,0 +1,799 @@ + + + + + + + + + + Morning Operational Digest + + + + + +
+ Your control plane is nominal. + ͏‌ ͏‌ ͏‌ ͏‌ ͏‌  +
+ + + + + + + + \ No newline at end of file diff --git a/nexus/communication/email/templates/sample_context.json b/nexus/communication/email/templates/sample_context.json new file mode 100644 index 0000000..0956d68 --- /dev/null +++ b/nexus/communication/email/templates/sample_context.json @@ -0,0 +1,104 @@ +{ + "_comment": "Concrete placeholder payloads per template. Design-first reference for the data each email expects. Mirrors the PLACEHOLDER DATA SCHEMA in EMAIL_DESIGN_SYSTEM.md. Not loaded by any service.", + "_shared": { + "version": "v1.2.0", + "timestamp": "Jun 26, 2026 · 08:00 IST", + "brand_links": [ + {"label": "Dashboard", "href": "https://nexus.local/dashboard"}, + {"label": "Docs", "href": "https://nexus.local/docs"}, + {"label": "Settings", "href": "https://nexus.local/settings"} + ] + }, + "morning_digest": { + "eyebrow": "Morning Digest", + "subject": "Morning Operational Digest", + "subtitle": "Your control plane is nominal.", + "greeting": "Good morning", + "owner_name": "STiFLeR", + "summary_line": "All core subsystems nominal. Here is your operational picture for the day.", + "health": {"label": "Healthy", "status": "success", "metrics": [ + {"label": "Uptime", "value": "99.98%", "delta": "+0.04", "trend": "up"}, + {"label": "Tasks", "value": "24", "delta": "+6", "trend": "up"}, + {"label": "Failures", "value": "1", "delta": "-2", "trend": "down-good", "accent": "#DC2626"} + ]}, + "research": [{"title": "OpenAI unveils custom inference chip", "score": 5, "source": "hackernews", "url": "#", "summary": "First bespoke AI inference silicon, built with Broadcom."}], + "tasks": [["Ship priority feed", "queued", "P1"], ["Recovery check", "active", "P2"]], + "approvals": [{"title": "Deploy v1.2.0 to prod", "requester": "scheduler", "href": "#"}], + "scheduler": [{"time": "08:00", "title": "Briefing dispatched", "tone": "success", "body": "3 channels"}], + "runtime": [{"name": "nexus", "status": "success"}, {"name": "gemini", "status": "warning"}], + "alerts": [{"tone": "warning", "title": "Throughput dip", "body": "-8% vs the 7-day average."}], + "recommendations": ["Investigate gemini latency before the 10:00 batch."], + "actions": [{"label": "Open dashboard", "href": "#", "variant": "primary"}] + }, + "approval_required": { + "eyebrow": "Approval Required", + "subject": "Approval Required — Deploy v1.2.0", + "request": {"requester": "scheduler", "task_title": "Deploy v1.2.0 to production", "task_description": "Roll out the priority-feed release.", "runtime": "nexus", "repository": "workspace_root", "reason": "Scheduled release window opened.", "risk": "MEDIUM", "risk_tone": "warning", "files": ["nexus/api.py", "nexus/config.py"], "approve_url": "#", "reject_url": "#", "audit_id": "a1b2c3d4", "expires": "in 4 hours"} + }, + "execution_failed": { + "eyebrow": "Execution Failed", + "subject": "Execution Failed — compile stage", + "incident": {"task_title": "Build research bundle", "task_id": "t-8842", "runtime": "gemini", "stage": "compile", "duration": "3.1s", "exit_status": "failure", "stages": [{"time": "10:00:05", "title": "Compile failed", "tone": "danger", "body": "Missing dependency"}], "stderr": "error: module not found", "stack": "Traceback...\n RuntimeError", "root_cause": "Tool not registered in the runtime adapter.", "recovery": ["Register the tool.", "Re-queue the task."], "retry_url": "#", "logs_url": "#", "audit_id": "f7e6d5c4"} + }, + "execution_completed": { + "eyebrow": "Execution Completed", + "subject": "Execution Completed — Build research bundle", + "run": {"task_title": "Build research bundle", "task_id": "t-8843", "runtime": "nexus", "duration": "12.4s", "summary": "Completed successfully with 2 artifacts.", "metrics": [{"label": "Duration", "value": "12.4s", "trend": "flat"}], "artifacts": [{"name": "bundle.json", "size": "18KB", "href": "#"}], "outputs": ["bundle written to ./out"], "logs_url": "#", "audit_id": "c4d5"} + }, + "research_report": { + "eyebrow": "Research Intelligence", + "subject": "Research Intelligence — Custom silicon", + "brief": {"headline": "Custom silicon reshapes inference economics", "importance": "HIGH SIGNAL", "importance_tone": "info", "summary": "Vendors are moving to bespoke inference chips.", "insights": ["Cost per token is falling fast."], "sources": [{"title": "OpenAI chip", "source": "hn", "url": "#", "score": 5}], "recommendations": ["Re-evaluate the provider mix."], "tags": ["ai", "hardware"], "actions": [{"label": "Read more", "href": "#"}]} + }, + "todo_digest": { + "eyebrow": "TODO Digest", + "subject": "Your TODO Digest", + "todo": {"today": [["Ship feed", "P1", "2h"]], "upcoming": [["Docs", "Fri"]], "blocked": [{"title": "Migration", "reason": "awaiting approval"}], "completed": ["Wrote tests"], "quick_links": [{"label": "Open board", "href": "#"}]} + }, + "reminder": { + "eyebrow": "Reminder", + "subject": "Reminder — Standup at 10:00", + "reminder": {"time": "in 30 min", "reason": "Standup at 10:00", "context": "Daily sync with the team.", "suggested_action": "Prep your update.", "cta_label": "Mark as done", "cta_url": "#"} + }, + "security_alert": { + "eyebrow": "Security Alert", + "subject": "Security Alert — Sandbox egress blocked", + "alert": {"severity": "HIGH", "severity_tone": "danger", "category": "sandbox", "summary": "An outbound connection was blocked by policy.", "details": [["Policy", "network=none"], ["Runtime", "nexus"]], "evidence": "blocked connect 1.2.3.4:443", "operator_actions": ["Review the policy.", "Acknowledge the alert."], "ack_url": "#", "audit_id": "s3a4"} + }, + "scheduler_report": { + "eyebrow": "Scheduler Report", + "subject": "Scheduler Report — 24h", + "scheduler": {"health_label": "All jobs nominal", "health_status": "success", "metrics": [{"label": "Jobs", "value": "18", "trend": "flat"}], "jobs": [["research_collection", "Succeeded", "12s"], ["approval_sweep", "Skipped", "—"]], "events": [{"time": "08:00", "title": "Briefing", "tone": "success"}]} + }, + "weekly_review": { + "eyebrow": "Weekly Operations", + "subject": "Weekly Operational Review", + "review": {"kpis": [{"label": "Tasks", "value": "180", "delta": "+12%", "trend": "up"}], "trends": [{"label": "Throughput", "pct": 80, "tone": "success"}], "reliability": [["Uptime", "99.98%"]], "tasks": [["Completed", "168"]], "llm_usage": [["Tokens", "1.2M"]], "cost": [["Spend", "$0.00"]], "recommendations": ["Maintain provider mix."]} + }, + "monthly_executive": { + "eyebrow": "Monthly Executive Report", + "subject": "Monthly Executive Report — June 2026", + "exec": {"highlights": ["Shipped v1.2.0 with the proactive priority feed."], "kpis": [{"label": "Uptime", "value": "99.95%", "trend": "up"}], "growth": [{"label": "Throughput", "pct": 65, "tone": "success"}], "reliability": [["Recoveries", "7"]], "execution": [["Tasks", "720"]], "research": [["Findings", "1,680"]], "architecture": ["Added the transport-independent channel harness."], "recommendations": ["Invest in evaluation harnesses."], "roadmap": [{"time": "Jul", "title": "Slack adapter", "tone": "info"}]} + }, + "qa_transcript": { + "eyebrow": "Q&A Transcript", + "subject": "Session transcript", + "transcript": {"participants": ["STiFLeR", "Dex"], "turns": [{"role": "operator", "name": "You", "time": "12:00", "text": "Mail me the Claude login URL."}, {"role": "dex", "name": "Dex", "time": "12:00", "text": "Sent it to your inbox."}, {"role": "system", "text": "email_sent_successfully"}]} + }, + "conversation_summary": { + "eyebrow": "Conversation Summary", + "subject": "Conversation Summary", + "convo": {"tldr": "Discussed the priority feed and email wiring.", "topics": ["feed", "email"], "key_points": ["The priority feed is live."], "decisions": ["Ship to v1.2."], "action_items": [{"text": "Push the branch", "owner": "stifler", "due": "today", "done": false}], "follow_ups": ["Wire reminders next."], "references": [{"label": "PR #42", "href": "#"}]} + }, + "action_items": { + "eyebrow": "Action Items", + "subject": "Action Items — 3 open", + "summary_line": "You have 3 open action items.", + "items": [{"text": "Push the v1.2 branch", "owner": "stifler", "due": "today", "priority": "P1", "priority_tone": "danger", "done": false, "href": "#"}, {"text": "Wrote E2E tests", "done": true}] + }, + "decision_summary": { + "eyebrow": "Decision Summary", + "subject": "Decision Summary", + "decisions": [{"title": "Adopt Jinja2 for the email system", "decision": "Use Jinja2 templates with component macros.", "rationale": "Native to the Python stack; no new toolchain.", "alternatives": ["MJML", "Hand-rolled string templates"], "owner": "stifler", "date": "Jun 26", "status": "Decided", "status_tone": "success"}] + } +}