diff --git a/configs/openjarvis/prompts/personas/jarvis.md b/configs/openjarvis/prompts/personas/jarvis.md index 4cd387c5..28cfda1d 100644 --- a/configs/openjarvis/prompts/personas/jarvis.md +++ b/configs/openjarvis/prompts/personas/jarvis.md @@ -1,32 +1,19 @@ -You are JARVIS — a highly capable AI assistant modelled after the AI companion from Iron Man. You deliver morning briefings based on real data. +You are Jarvis — the local AI assistant. You are loyal, efficient, dry-witted, and genuinely care about the person you serve. You have a warm British sensibility: polite but never obsequious, witty but never frivolous. -## Voice & Tone -- Professional, concise, with dry British wit -- Confident and direct — state facts, don't ramble -- Use "sir" sparingly for effect -- When delivering bad news, frame it constructively but honestly +PERSONALITY: +- You anticipate needs before being asked +- You deliver bad news honestly but constructively +- Your humor is dry and understated — a raised eyebrow in voice form +- You are calm under pressure and never flustered +- You treat the briefing as a conversation, not a report -## Critical Constraints -- ONLY report information that is present in the provided data -- NEVER invent details, statistics, or events not in the data -- NEVER describe actions you are taking (you cannot adjust lights, order food, queue playlists, etc.) -- NEVER use markdown formatting (no #, ##, *, -, bullet points) — this is spoken aloud -- Use natural spoken transitions: "Regarding your health", "Moving to your schedule", "As for your messages" -- Keep the entire briefing under 200 words -- If a data source returned no results, skip that section silently +ADDRESS: +- Use the user's preferred honorific (provided in the system prompt) +- Use it 2-3 times per briefing: once in greeting, once mid-briefing, once in closing +- Never every sentence — that would be a parody, not Jarvis -## Structure -- ALWAYS open with "Good morning, sir." (or "Good afternoon, sir." / "Good evening, sir." based on time of day) -- For EACH section, start with a one-sentence summary (e.g. "Your health data looks solid today" or "You have a busy inbox with fifteen new emails"), then give the key details, then transition naturally to the next section -- Deliver each section crisply with real numbers from the data -- Close with a brief encouraging or forward-looking sentence (e.g. "You're well-positioned for the day ahead, sir." or "A productive day awaits.") - -## Examples of GOOD responses -- "Good morning, sir. Your Oura data shows you slept six hours and forty minutes with an average heart rate of fifty-eight. Your HRV was forty-nine, which is within your normal range." -- "You have three emails requiring attention and two tasks due this week." -- "Your recently played tracks include Billie Eilish and Sabrina Carpenter." - -## Examples of BAD responses (never do this) -- "I have adjusted the ambient lighting to assist with alertness." (fabricated action) -- "I have queued a caffeine solution for delivery." (fabricated action) -- "The semiconductor markets are volatile today." (not in provided data) +CONSTRAINTS: +- ONLY report facts present in the provided data. Never invent. +- NEVER describe actions you are taking (adjusting lights, ordering food, queuing playlists, etc.) +- No markdown formatting, no emojis, no bullet points, no headers — this is spoken aloud +- Skip sections entirely if they have no data — do not mention their absence diff --git a/src/openjarvis/agents/morning_digest.py b/src/openjarvis/agents/morning_digest.py index 58bdf866..c9a34338 100644 --- a/src/openjarvis/agents/morning_digest.py +++ b/src/openjarvis/agents/morning_digest.py @@ -44,45 +44,61 @@ class MorningDigestAgent(ToolUsingAgent): self._section_sources = kwargs.pop("section_sources", {}) self._timezone = kwargs.pop("timezone", "America/Los_Angeles") self._voice_id = kwargs.pop("voice_id", "") + self._voice_speed = kwargs.pop("voice_speed", 1.0) self._tts_backend = kwargs.pop("tts_backend", "cartesia") self._digest_store_path = kwargs.pop("digest_store_path", "") + self._honorific = kwargs.pop("honorific", "sir") super().__init__(*args, **kwargs) def _build_system_prompt(self) -> str: - """Assemble the system prompt from persona + context.""" + """Assemble the system prompt from persona + briefing structure.""" persona_text = _load_persona(self._persona) now = datetime.now() + honorific = getattr(self, "_honorific", "sir") return ( f"{persona_text}\n\n" f"Today is {now.strftime('%A, %B %d, %Y')}. " - f"The time is {now.strftime('%I:%M %p')} in {self._timezone}.\n\n" - f"Generate a morning briefing covering: " - f"{', '.join(self._sections)}.\n\n" - "CRITICAL RULES:\n" - "1. ONLY report facts that appear in the provided data. " - "Do NOT invent, hallucinate, or embellish any information.\n" - "2. Do NOT describe actions you are taking (e.g. 'I have adjusted " - "the lights', 'I have queued a delivery'). You are reporting, not " - "acting.\n" - "3. Do NOT use any markdown formatting — no #, ##, *, -, or bullet " - "points. This text will be read aloud as speech.\n" - "4. Use natural spoken transitions between sections like " - "'Regarding your health', 'Moving on to your calendar', " - "'As for your messages'.\n" - "5. Be concise — under 200 words total. State facts directly.\n" - "6. If a section has no data, skip it entirely rather than " - "making something up." + f"The time is {now.strftime('%I:%M %p')} in {self._timezone}.\n" + f"The user's preferred honorific is: {honorific}\n\n" + "You receive structured data from the user's connected services " + "(calendar, email, health tracker, etc.). The data has ALREADY been " + "collected for you — it appears in the user message. You do NOT need " + "to fetch or access anything yourself.\n\n" + "Produce a spoken morning briefing following this structure:\n\n" + "1. GREETING — One sentence with the honorific, framing the day.\n" + "2. PRIORITIES — What needs attention NOW. Overdue deadlines first, " + "then today's deadlines, then urgent messages needing a reply. " + "Connect related items across sections.\n" + "3. SCHEDULE — Upcoming events only (skip past ones). Be time-aware: " + "'You have 3 hours before your next commitment.'\n" + "4. MESSAGES — Who reached out and what they need. Lead with messages " + "requiring a reply, then FYI items. Quote actual message text when " + "relevant.\n" + "5. HEALTH — Interpret, don't list. 'Your sleep has improved three " + "nights running' not 'HRV 53, HR 56 bpm.' Compare to trends.\n" + "6. WORLD — Weather, news, notable developments. Skip if no data.\n" + "7. CLOSING — One encouraging or forward-looking sentence.\n\n" + "RULES:\n" + "- ONLY report facts from the provided data. Never invent.\n" + "- NEVER describe actions you are taking.\n" + "- Skip sections with no data entirely.\n" + "- No markdown, emojis, bullet points, or headers.\n" + "- Natural spoken transitions between sections.\n" + "- Under 250 words total." ) def _resolve_sources(self) -> List[str]: """Get the list of connector IDs to query.""" default_source_map = { - "messages": ["gmail", "slack", "google_tasks"], + "messages": [ + "gmail", "slack", "google_tasks", + "imessage", "github_notifications", + ], "calendar": ["gcalendar"], "health": ["oura", "apple_health"], + "world": ["weather", "hackernews", "news_rss"], "music": ["spotify", "apple_music"], - "world": [], # Handled by web_search, not connectors } sources = set() for section in self._sections: @@ -119,12 +135,10 @@ class MorningDigestAgent(ToolUsingAgent): content=( f"Here is the collected data from my sources:\n\n" f"{collected_data}\n\n" - f"Synthesize my morning briefing. REMEMBER:\n" - f"- Start with 'Good morning, sir.' (or afternoon/evening)\n" - f"- NO markdown, NO emojis, NO bullet points, NO headers\n" - f"- Plain spoken English only — this is read aloud\n" - f"- Only state facts from the data above\n" - f"- Under 200 words" + f"Synthesize my morning briefing. Remember: " + f"priority-first, interpret health trends, " + f"quote important message text, connect related items, " + f"plain spoken English only, under 250 words." ), ), ] @@ -149,6 +163,7 @@ class MorningDigestAgent(ToolUsingAgent): "text": tts_text, "voice_id": self._voice_id, "backend": self._tts_backend, + "speed": self._voice_speed, } ), ) diff --git a/src/openjarvis/connectors/__init__.py b/src/openjarvis/connectors/__init__.py index 597dd103..2c59dee8 100644 --- a/src/openjarvis/connectors/__init__.py +++ b/src/openjarvis/connectors/__init__.py @@ -107,3 +107,23 @@ try: import openjarvis.connectors.google_tasks # noqa: F401 except ImportError: pass + +try: + import openjarvis.connectors.weather # noqa: F401 +except ImportError: + pass + +try: + import openjarvis.connectors.github_notifications # noqa: F401 +except ImportError: + pass + +try: + import openjarvis.connectors.hackernews # noqa: F401 +except ImportError: + pass + +try: + import openjarvis.connectors.news_rss # noqa: F401 +except ImportError: + pass diff --git a/src/openjarvis/connectors/github_notifications.py b/src/openjarvis/connectors/github_notifications.py new file mode 100644 index 00000000..9bd96a8e --- /dev/null +++ b/src/openjarvis/connectors/github_notifications.py @@ -0,0 +1,111 @@ +"""GitHub Notifications connector — unread notifications via GitHub REST API. + +Uses a Personal Access Token stored in the connector config dir. +All API calls are in module-level functions for easy mocking in tests. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional + +import httpx + +from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus +from openjarvis.core.config import DEFAULT_CONFIG_DIR +from openjarvis.core.registry import ConnectorRegistry + +_DEFAULT_TOKEN_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "github.json") + + +def _github_api_get( + token: str, params: Optional[Dict[str, str]] = None +) -> List[Dict[str, Any]]: + """Fetch notifications from the GitHub API.""" + resp = httpx.get( + "https://api.github.com/notifications", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + params=params or {}, + timeout=30.0, + ) + resp.raise_for_status() + return resp.json() + + +@ConnectorRegistry.register("github_notifications") +class GitHubNotificationsConnector(BaseConnector): + """Sync unread notifications from GitHub.""" + + connector_id = "github_notifications" + display_name = "GitHub Notifications" + auth_type = "token" + + def __init__(self, *, token_path: str = _DEFAULT_TOKEN_PATH) -> None: + self._token_path = Path(token_path) + self._status = SyncStatus() + + def _load_token(self) -> str: + """Load the GitHub PAT from disk.""" + data = json.loads(self._token_path.read_text(encoding="utf-8")) + return data["token"] + + def is_connected(self) -> bool: + return self._token_path.exists() + + def disconnect(self) -> None: + if self._token_path.exists(): + self._token_path.unlink() + + def sync( + self, *, since: Optional[datetime] = None, cursor: Optional[str] = None + ) -> Iterator[Document]: + """Yield Documents for each GitHub notification.""" + token = self._load_token() + params: Dict[str, str] = {} + if since is not None: + params["since"] = f"{since.isoformat()}Z" + + notifications = _github_api_get(token, params=params) + + for notif in notifications: + subject = notif.get("subject", {}) + repo = notif.get("repository", {}).get("full_name", "") + reason = notif.get("reason", "") + notif_type = subject.get("type", "") + title = subject.get("title", "") + notif_id = notif.get("id", "") + updated_at = notif.get("updated_at", "") + + content = f"Reason: {reason}, Repository: {repo}" + ts = datetime.now() + if updated_at: + try: + ts = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) + except ValueError: + pass + + yield Document( + doc_id=f"github-notification-{notif_id}", + source="github_notifications", + doc_type="notification", + content=content, + title=title, + timestamp=ts, + url=subject.get("url"), + metadata={ + "reason": reason, + "repo": repo, + "type": notif_type, + }, + ) + + self._status.state = "idle" + self._status.last_sync = datetime.now() + + def sync_status(self) -> SyncStatus: + return self._status diff --git a/src/openjarvis/connectors/gmail.py b/src/openjarvis/connectors/gmail.py index 9412ae2d..ee157a83 100644 --- a/src/openjarvis/connectors/gmail.py +++ b/src/openjarvis/connectors/gmail.py @@ -253,11 +253,11 @@ class GmailConnector(BaseConnector): if not token: return - query = "" + query = "category:primary" if since is not None: # Gmail's after: operator accepts Unix epoch seconds. epoch = int(since.timestamp()) - query = f"after:{epoch}" + query = f"category:primary after:{epoch}" page_token: Optional[str] = cursor synced = 0 diff --git a/src/openjarvis/connectors/hackernews.py b/src/openjarvis/connectors/hackernews.py new file mode 100644 index 00000000..bcca1c51 --- /dev/null +++ b/src/openjarvis/connectors/hackernews.py @@ -0,0 +1,91 @@ +"""Hacker News connector — top stories from the HN Firebase API. + +No authentication required. All API calls are in module-level functions +for easy mocking in tests. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, Iterator, List, Optional + +import httpx + +from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus +from openjarvis.core.registry import ConnectorRegistry + +_HN_API_BASE = "https://hacker-news.firebaseio.com/v0" + + +def _hn_top_story_ids() -> List[int]: + """Fetch the list of top story IDs.""" + resp = httpx.get(f"{_HN_API_BASE}/topstories.json", timeout=30.0) + resp.raise_for_status() + return resp.json() + + +def _hn_item(item_id: int) -> Dict[str, Any]: + """Fetch a single HN item by ID.""" + resp = httpx.get(f"{_HN_API_BASE}/item/{item_id}.json", timeout=30.0) + resp.raise_for_status() + return resp.json() + + +@ConnectorRegistry.register("hackernews") +class HackerNewsConnector(BaseConnector): + """Fetch the current top stories from Hacker News.""" + + connector_id = "hackernews" + display_name = "Hacker News" + auth_type = "local" + + def __init__(self) -> None: + self._status = SyncStatus() + + def is_connected(self) -> bool: + return True + + def disconnect(self) -> None: + pass # No credentials to revoke + + def sync( + self, *, since: Optional[datetime] = None, cursor: Optional[str] = None + ) -> Iterator[Document]: + """Yield Documents for the top 5 Hacker News stories.""" + top_ids = _hn_top_story_ids() + + for story_id in top_ids[:5]: + item = _hn_item(story_id) + if item is None: + continue + + title = item.get("title", "") + score = item.get("score", 0) + descendants = item.get("descendants", 0) + url = item.get("url", "") + by = item.get("by", "") + ts = datetime.now() + if item.get("time"): + ts = datetime.fromtimestamp(item["time"]) + + yield Document( + doc_id=f"hn-{story_id}", + source="hackernews", + doc_type="story", + content=f"Score: {score}, Comments: {descendants}", + title=title, + author=by, + timestamp=ts, + url=url or None, + metadata={ + "story_id": story_id, + "score": score, + "descendants": descendants, + }, + ) + + self._status.state = "idle" + self._status.last_sync = datetime.now() + + def sync_status(self) -> SyncStatus: + return self._status diff --git a/src/openjarvis/connectors/news_rss.py b/src/openjarvis/connectors/news_rss.py new file mode 100644 index 00000000..d4a61e4d --- /dev/null +++ b/src/openjarvis/connectors/news_rss.py @@ -0,0 +1,158 @@ +"""News/RSS connector — aggregate headlines from RSS and Atom feeds. + +Uses stdlib xml.etree.ElementTree for parsing (no extra dependencies). +Config file lists feeds to follow. All HTTP calls are in module-level +functions for easy mocking in tests. +""" + +from __future__ import annotations + +import json +import xml.etree.ElementTree as ET +from datetime import datetime +from email.utils import parsedate_to_datetime +from pathlib import Path +from typing import Dict, Iterator, List, Optional + +import httpx + +from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus +from openjarvis.core.config import DEFAULT_CONFIG_DIR +from openjarvis.core.registry import ConnectorRegistry + +_DEFAULT_CONFIG_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "news_rss.json") + + +def _fetch_feed(url: str) -> str: + """Download raw XML from a feed URL.""" + resp = httpx.get(url, timeout=30.0, follow_redirects=True) + resp.raise_for_status() + return resp.text + + +def _parse_rss_items(xml_text: str, max_items: int = 5) -> List[Dict[str, str]]: + """Parse RSS or Atom XML and return up to *max_items* entries.""" + root = ET.fromstring(xml_text) + items: List[Dict[str, str]] = [] + + # RSS 2.0: + for item_el in root.iter("item"): + if len(items) >= max_items: + break + items.append({ + "title": (item_el.findtext("title") or "").strip(), + "description": (item_el.findtext("description") or "").strip()[:200], + "link": (item_el.findtext("link") or "").strip(), + "pubDate": (item_el.findtext("pubDate") or "").strip(), + }) + + # Atom: + if not items: + ns = {"atom": "http://www.w3.org/2005/Atom"} + for entry_el in root.iter("{http://www.w3.org/2005/Atom}entry"): + if len(items) >= max_items: + break + link_el = entry_el.find("atom:link", ns) + link_href = link_el.get("href", "") if link_el is not None else "" + summary = entry_el.findtext("{http://www.w3.org/2005/Atom}summary") or "" + updated = entry_el.findtext("{http://www.w3.org/2005/Atom}updated") or "" + items.append({ + "title": ( + entry_el.findtext("{http://www.w3.org/2005/Atom}title") or "" + ).strip(), + "description": summary.strip()[:200], + "link": link_href.strip(), + "pubDate": updated.strip(), + }) + + return items + + +def _parse_pub_date(date_str: str) -> Optional[datetime]: + """Best-effort parse of an RSS pubDate or Atom updated timestamp.""" + if not date_str: + return None + try: + return parsedate_to_datetime(date_str) + except (ValueError, TypeError): + pass + try: + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except (ValueError, TypeError): + return None + + +@ConnectorRegistry.register("news_rss") +class NewsRSSConnector(BaseConnector): + """Aggregate headlines from configured RSS/Atom feeds.""" + + connector_id = "news_rss" + display_name = "News / RSS" + auth_type = "local" + + def __init__(self, *, config_path: str = _DEFAULT_CONFIG_PATH) -> None: + self._config_path = Path(config_path) + self._status = SyncStatus() + + def _load_config(self) -> List[Dict[str, str]]: + """Load feed list from disk.""" + data = json.loads(self._config_path.read_text(encoding="utf-8")) + return data.get("feeds", []) + + def is_connected(self) -> bool: + if not self._config_path.exists(): + return False + try: + feeds = self._load_config() + return len(feeds) > 0 + except (json.JSONDecodeError, OSError): + return False + + def disconnect(self) -> None: + if self._config_path.exists(): + self._config_path.unlink() + + def sync( + self, *, since: Optional[datetime] = None, cursor: Optional[str] = None + ) -> Iterator[Document]: + """Yield Documents for recent items across all configured feeds.""" + feeds = self._load_config() + + for feed in feeds: + feed_name = feed.get("name", "Unknown Feed") + feed_url = feed.get("url", "") + if not feed_url: + continue + + try: + xml_text = _fetch_feed(feed_url) + except httpx.HTTPError: + continue + + items = _parse_rss_items(xml_text) + for item in items: + pub_dt = _parse_pub_date(item["pubDate"]) + + # Filter by since if the date is parseable + if since and pub_dt and pub_dt.replace(tzinfo=None) < since: + continue + + title = item["title"] or "Untitled" + doc_id = f"rss-{feed_name}-{title[:40]}" + + yield Document( + doc_id=doc_id, + source="news_rss", + doc_type="article", + content=item["description"], + title=title, + timestamp=pub_dt or datetime.now(), + url=item["link"] or None, + metadata={"feed_name": feed_name}, + ) + + self._status.state = "idle" + self._status.last_sync = datetime.now() + + def sync_status(self) -> SyncStatus: + return self._status diff --git a/src/openjarvis/connectors/weather.py b/src/openjarvis/connectors/weather.py new file mode 100644 index 00000000..c92291fa --- /dev/null +++ b/src/openjarvis/connectors/weather.py @@ -0,0 +1,135 @@ +"""Weather connector — current conditions and forecast via OpenWeatherMap API. + +Uses an API key stored in the connector config dir. +All API calls are in module-level functions for easy mocking in tests. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterator, Optional + +import httpx + +from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus +from openjarvis.core.config import DEFAULT_CONFIG_DIR +from openjarvis.core.registry import ConnectorRegistry + +_DEFAULT_TOKEN_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "weather.json") + + +def _weather_api_get( + url: str, params: Dict[str, str] +) -> Dict[str, Any]: + """Call an OpenWeatherMap API endpoint.""" + resp = httpx.get(url, params=params, timeout=30.0) + resp.raise_for_status() + return resp.json() + + +@ConnectorRegistry.register("weather") +class WeatherConnector(BaseConnector): + """Fetch current weather and short-term forecast from OpenWeatherMap.""" + + connector_id = "weather" + display_name = "Weather" + auth_type = "token" + + def __init__(self, *, token_path: str = _DEFAULT_TOKEN_PATH) -> None: + self._token_path = Path(token_path) + self._status = SyncStatus() + + def _load_config(self) -> Dict[str, str]: + """Load API key and location from disk.""" + data = json.loads(self._token_path.read_text(encoding="utf-8")) + return data + + def is_connected(self) -> bool: + if not self._token_path.exists(): + return False + try: + data = json.loads(self._token_path.read_text(encoding="utf-8")) + return bool(data.get("api_key")) + except (json.JSONDecodeError, OSError): + return False + + def disconnect(self) -> None: + if self._token_path.exists(): + self._token_path.unlink() + + def sync( + self, *, since: Optional[datetime] = None, cursor: Optional[str] = None + ) -> Iterator[Document]: + """Yield Documents for current weather and forecast.""" + config = self._load_config() + api_key = config["api_key"] + location = config.get("location", "San Francisco,CA") + + # Current weather + current = _weather_api_get( + "https://api.openweathermap.org/data/2.5/weather", + params={"q": location, "appid": api_key, "units": "imperial"}, + ) + main = current.get("main", {}) + weather_desc = ", ".join( + w.get("description", "") for w in current.get("weather", []) + ) + content = ( + f"Temperature: {main.get('temp')}°F, " + f"Conditions: {weather_desc}, " + f"Humidity: {main.get('humidity')}%, " + f"Wind: {current.get('wind', {}).get('speed')} mph" + ) + yield Document( + doc_id=f"weather-current-{location}", + source="weather", + doc_type="current", + content=content, + title=f"Current Weather — {location}", + timestamp=datetime.now(), + metadata={ + "location": location, + "temp": main.get("temp"), + "conditions": weather_desc, + "humidity": main.get("humidity"), + "wind_speed": current.get("wind", {}).get("speed"), + }, + ) + + # Forecast (next ~12 hours, 4 x 3-hour intervals) + forecast = _weather_api_get( + "https://api.openweathermap.org/data/2.5/forecast", + params={ + "q": location, + "appid": api_key, + "units": "imperial", + "cnt": "4", + }, + ) + summaries = [] + for entry in forecast.get("list", []): + dt_txt = entry.get("dt_txt", "") + temp = entry.get("main", {}).get("temp") + desc = ", ".join( + w.get("description", "") for w in entry.get("weather", []) + ) + summaries.append(f"{dt_txt}: {temp}°F, {desc}") + forecast_content = "Forecast:\n" + "\n".join(summaries) + + yield Document( + doc_id=f"weather-forecast-{location}", + source="weather", + doc_type="forecast", + content=forecast_content, + title=f"Weather Forecast — {location}", + timestamp=datetime.now(), + metadata={"location": location}, + ) + + self._status.state = "idle" + self._status.last_sync = datetime.now() + + def sync_status(self) -> SyncStatus: + return self._status diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index ba15d625..7d6c9821 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1258,7 +1258,9 @@ class DigestConfig: optional_sections: List[str] = field( default_factory=lambda: ["github", "financial", "music", "fitness"] ) + honorific: str = "sir" voice_id: str = "" + voice_speed: float = 1.0 tts_backend: str = "cartesia" messages: DigestSectionConfig = field( default_factory=lambda: DigestSectionConfig( diff --git a/src/openjarvis/sdk.py b/src/openjarvis/sdk.py index 10fd2c69..c6410978 100644 --- a/src/openjarvis/sdk.py +++ b/src/openjarvis/sdk.py @@ -496,7 +496,9 @@ class Jarvis: "section_sources": section_sources, "timezone": dc.timezone, "voice_id": dc.voice_id, + "voice_speed": dc.voice_speed, "tts_backend": dc.tts_backend, + "honorific": dc.honorific, }) # Ensure digest agent always has its required tools from openjarvis.tools.digest_collect import DigestCollectTool diff --git a/src/openjarvis/system.py b/src/openjarvis/system.py index 09ae0303..f4a64f79 100644 --- a/src/openjarvis/system.py +++ b/src/openjarvis/system.py @@ -225,7 +225,9 @@ class JarvisSystem: "section_sources": section_sources, "timezone": dc.timezone, "voice_id": dc.voice_id, + "voice_speed": dc.voice_speed, "tts_backend": dc.tts_backend, + "honorific": dc.honorific, }) # Ensure digest agent always has its required tools from openjarvis.tools.digest_collect import DigestCollectTool diff --git a/src/openjarvis/tools/digest_collect.py b/src/openjarvis/tools/digest_collect.py index a0cb5a0d..688f85f3 100644 --- a/src/openjarvis/tools/digest_collect.py +++ b/src/openjarvis/tools/digest_collect.py @@ -28,9 +28,12 @@ _SECTION_ORDER: List[tuple] = [ "imessage", "whatsapp", "outlook", + "notion", + "github_notifications", }, ), ("CALENDAR", {"gcalendar"}), + ("WORLD", {"weather", "hackernews", "news_rss"}), ("MUSIC", {"spotify", "apple_music"}), ] @@ -156,10 +159,14 @@ def _format_strava(doc: Document) -> str: def _format_gmail(doc: Document) -> str: """Format a Gmail email document.""" sender = doc.author or "Unknown" - # Clean up sender: extract just email or name subject = doc.title or "(no subject)" ago = _time_ago(doc.timestamp) - return f'[gmail] From: {sender} — "{subject}" ({ago})' + # Include body preview (first 150 chars, single line) + body = doc.content.replace("\n", " ").strip()[:150] if doc.content else "" + line = f'[gmail] From: {sender} — "{subject}" ({ago})' + if body: + line += f"\n Preview: {body}" + return line def _format_gmail_imap(doc: Document) -> str: @@ -190,16 +197,26 @@ def _format_slack(doc: Document) -> str: """Format a Slack message document.""" author = doc.author or "Unknown" channel = doc.metadata.get("channel", "") - content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else "" + ago = _time_ago(doc.timestamp) + snippet = doc.content[:150].replace("\n", " ").strip() + content_preview = snippet if doc.content else "" prefix = f"[slack] #{channel}" if channel else "[slack]" - return f"{prefix} {author}: {content_preview}" + line = f"{prefix} {author} ({ago})" + if content_preview: + line += f": {content_preview}" + return line def _format_imessage(doc: Document) -> str: """Format an iMessage document.""" sender = doc.author or "Unknown" - content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else "" - return f"[imessage] {sender}: {content_preview}" + ago = _time_ago(doc.timestamp) + snippet = doc.content[:150].replace("\n", " ").strip() + content_preview = snippet if doc.content else "" + line = f"[imessage] {sender} ({ago})" + if content_preview: + line += f": {content_preview}" + return line def _format_whatsapp(doc: Document) -> str: @@ -217,6 +234,13 @@ def _format_outlook(doc: Document) -> str: return f'[outlook] From: {sender} — "{subject}" ({ago})' +def _format_notion(doc: Document) -> str: + """Format a Notion page document.""" + title = doc.title or "Untitled" + ago = _time_ago(doc.timestamp) + return f"[notion] {title} (updated {ago})" + + def _format_gcalendar(doc: Document) -> str: """Format a Google Calendar event document.""" title = doc.title or "(No title)" @@ -262,6 +286,48 @@ def _format_apple_music(doc: Document) -> str: return doc.title +def _format_weather(doc: Document) -> str: + """Format a weather document.""" + data = _parse_content_json(doc) + if doc.doc_type == "current": + temp = data.get("temp_f", "?") + cond = data.get("conditions", "?") + humidity = data.get("humidity", "?") + return f"[weather] Current: {temp}°F, {cond}, humidity {humidity}%" + if doc.doc_type == "forecast": + return f"[weather] Forecast: {doc.content[:200]}" + return f"[weather] {doc.title}" + + +def _format_github_notifications(doc: Document) -> str: + """Format a GitHub notification.""" + reason = doc.metadata.get("reason", "") + repo = doc.metadata.get("repo", "") + title = doc.title or "(no title)" + ago = _time_ago(doc.timestamp) + reason_str = f" ({reason})" if reason else "" + repo_str = f" in {repo}" if repo else "" + return f"[github] {title}{repo_str}{reason_str} ({ago})" + + +def _format_hackernews(doc: Document) -> str: + """Format a Hacker News story.""" + score = doc.metadata.get("score", "?") + comments = doc.metadata.get("descendants", "?") + return f"[hackernews] {doc.title} (score {score}, {comments} comments)" + + +def _format_news_rss(doc: Document) -> str: + """Format an RSS news item.""" + feed_name = doc.metadata.get("feed_name", "") + prefix = f"[{feed_name}]" if feed_name else "[news]" + description = doc.content[:150].replace("\n", " ").strip() if doc.content else "" + line = f"{prefix} {doc.title}" + if description: + line += f" — {description}" + return line + + # Map connector IDs to their formatting functions _FORMATTERS: Dict[str, Any] = { "oura": _format_oura, @@ -274,7 +340,12 @@ _FORMATTERS: Dict[str, Any] = { "imessage": _format_imessage, "whatsapp": _format_whatsapp, "outlook": _format_outlook, + "notion": _format_notion, "gcalendar": _format_gcalendar, + "weather": _format_weather, + "github_notifications": _format_github_notifications, + "hackernews": _format_hackernews, + "news_rss": _format_news_rss, "spotify": _format_spotify, "apple_music": _format_apple_music, } diff --git a/src/openjarvis/tools/text_to_speech.py b/src/openjarvis/tools/text_to_speech.py index a0378bf9..7de2a0c7 100644 --- a/src/openjarvis/tools/text_to_speech.py +++ b/src/openjarvis/tools/text_to_speech.py @@ -60,6 +60,7 @@ class TextToSpeechTool(BaseTool): voice_id = params.get("voice_id", "") backend_key = params.get("backend", "cartesia") output_dir = params.get("output_dir", "") + speed = float(params.get("speed", 1.0)) if not text: return ToolResult( @@ -78,7 +79,7 @@ class TextToSpeechTool(BaseTool): backend_cls = TTSRegistry.get(backend_key) backend = backend_cls() - result = backend.synthesize(text, voice_id=voice_id) + result = backend.synthesize(text, voice_id=voice_id, speed=speed) # Save to file if output_dir: diff --git a/tests/connectors/test_github_notifications.py b/tests/connectors/test_github_notifications.py new file mode 100644 index 00000000..3620f262 --- /dev/null +++ b/tests/connectors/test_github_notifications.py @@ -0,0 +1,105 @@ +"""Tests for GitHubNotificationsConnector — GitHub REST API.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +import pytest + +from openjarvis.connectors._stubs import Document +from openjarvis.core.registry import ConnectorRegistry + + +def test_github_notifications_registered(): + """GitHubNotificationsConnector is discoverable via ConnectorRegistry.""" + from openjarvis.connectors.github_notifications import ( + GitHubNotificationsConnector, + ) + + ConnectorRegistry.register_value( + "github_notifications", GitHubNotificationsConnector + ) + assert ConnectorRegistry.contains("github_notifications") + cls = ConnectorRegistry.get("github_notifications") + assert cls.connector_id == "github_notifications" + assert cls.auth_type == "token" + + +_NOTIFICATIONS_RESPONSE = [ + { + "id": "1001", + "reason": "review_requested", + "updated_at": "2026-04-01T10:00:00Z", + "subject": { + "title": "Add caching layer to inference engine", + "type": "PullRequest", + "url": "https://api.github.com/repos/org/repo/pulls/42", + }, + "repository": {"full_name": "org/repo"}, + }, + { + "id": "1002", + "reason": "mention", + "updated_at": "2026-04-01T11:30:00Z", + "subject": { + "title": "Bug: memory leak in long sessions", + "type": "Issue", + "url": "https://api.github.com/repos/org/repo/issues/99", + }, + "repository": {"full_name": "org/repo"}, + }, +] + + +@pytest.fixture() +def connector(tmp_path): + """GitHubNotificationsConnector with fake token file.""" + from openjarvis.connectors.github_notifications import ( + GitHubNotificationsConnector, + ) + + token_path = tmp_path / "github.json" + token_path.write_text('{"token": "ghp_fake123"}', encoding="utf-8") + return GitHubNotificationsConnector(token_path=str(token_path)) + + +def test_is_connected(connector): + assert connector.is_connected() is True + + +def test_is_connected_no_file(tmp_path): + from openjarvis.connectors.github_notifications import ( + GitHubNotificationsConnector, + ) + + c = GitHubNotificationsConnector(token_path=str(tmp_path / "missing.json")) + assert c.is_connected() is False + + +def test_sync_yields_documents(connector): + """Sync returns Documents for each notification.""" + with patch( + "openjarvis.connectors.github_notifications._github_api_get", + return_value=_NOTIFICATIONS_RESPONSE, + ): + docs = list(connector.sync(since=datetime(2026, 4, 1))) + + assert len(docs) == 2 + assert all(isinstance(d, Document) for d in docs) + + pr_doc = docs[0] + assert pr_doc.source == "github_notifications" + assert pr_doc.title == "Add caching layer to inference engine" + assert "review_requested" in pr_doc.content + assert pr_doc.metadata["repo"] == "org/repo" + assert pr_doc.metadata["type"] == "PullRequest" + + issue_doc = docs[1] + assert issue_doc.title == "Bug: memory leak in long sessions" + assert issue_doc.metadata["reason"] == "mention" + + +def test_disconnect(connector): + connector.disconnect() + assert connector.is_connected() is False diff --git a/tests/connectors/test_hackernews.py b/tests/connectors/test_hackernews.py new file mode 100644 index 00000000..1a615088 --- /dev/null +++ b/tests/connectors/test_hackernews.py @@ -0,0 +1,124 @@ +"""Tests for HackerNewsConnector — HN Firebase API.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from openjarvis.connectors._stubs import Document +from openjarvis.core.registry import ConnectorRegistry + + +def test_hackernews_registered(): + """HackerNewsConnector is discoverable via ConnectorRegistry.""" + from openjarvis.connectors.hackernews import HackerNewsConnector + + ConnectorRegistry.register_value("hackernews", HackerNewsConnector) + assert ConnectorRegistry.contains("hackernews") + cls = ConnectorRegistry.get("hackernews") + assert cls.connector_id == "hackernews" + assert cls.auth_type == "local" + + +_TOP_STORY_IDS = [101, 102, 103, 104, 105, 106, 107] + +_STORY_ITEMS = { + 101: { + "id": 101, + "title": "Show HN: A new Rust web framework", + "score": 350, + "descendants": 120, + "url": "https://example.com/rust-framework", + "by": "rustdev", + "time": 1743523200, + }, + 102: { + "id": 102, + "title": "Why SQLite is the future of edge computing", + "score": 210, + "descendants": 85, + "url": "https://example.com/sqlite-edge", + "by": "dbfan", + "time": 1743523300, + }, + 103: { + "id": 103, + "title": "Launch HN: AI agent startup", + "score": 180, + "descendants": 60, + "url": "", + "by": "founder", + "time": 1743523400, + }, + 104: { + "id": 104, + "title": "Understanding memory-mapped files", + "score": 95, + "descendants": 30, + "url": "https://example.com/mmap", + "by": "sysprog", + "time": 1743523500, + }, + 105: { + "id": 105, + "title": "Open-source LLM benchmarks are broken", + "score": 420, + "descendants": 200, + "url": "https://example.com/llm-benchmarks", + "by": "mlresearcher", + "time": 1743523600, + }, +} + + +@pytest.fixture() +def connector(): + from openjarvis.connectors.hackernews import HackerNewsConnector + + return HackerNewsConnector() + + +def test_is_connected(connector): + assert connector.is_connected() is True + + +def test_sync_yields_top_five(connector): + """Sync returns Documents for the top 5 stories.""" + + def mock_item(item_id): + return _STORY_ITEMS[item_id] + + with ( + patch( + "openjarvis.connectors.hackernews._hn_top_story_ids", + return_value=_TOP_STORY_IDS, + ), + patch( + "openjarvis.connectors.hackernews._hn_item", + side_effect=mock_item, + ), + ): + docs = list(connector.sync()) + + assert len(docs) == 5 + assert all(isinstance(d, Document) for d in docs) + + first = docs[0] + assert first.source == "hackernews" + assert first.doc_type == "story" + assert first.title == "Show HN: A new Rust web framework" + assert "Score: 350" in first.content + assert "Comments: 120" in first.content + assert first.author == "rustdev" + assert first.url == "https://example.com/rust-framework" + + # Story with empty URL should have url=None + third = docs[2] + assert third.url is None + + +def test_disconnect_is_noop(connector): + """Disconnect should succeed without error.""" + connector.disconnect() + assert connector.is_connected() is True diff --git a/tests/connectors/test_news_rss.py b/tests/connectors/test_news_rss.py new file mode 100644 index 00000000..5e1534d9 --- /dev/null +++ b/tests/connectors/test_news_rss.py @@ -0,0 +1,169 @@ +"""Tests for NewsRSSConnector — RSS/Atom feed aggregator.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +import pytest + +from openjarvis.connectors._stubs import Document +from openjarvis.core.registry import ConnectorRegistry + + +def test_news_rss_registered(): + """NewsRSSConnector is discoverable via ConnectorRegistry.""" + from openjarvis.connectors.news_rss import NewsRSSConnector + + ConnectorRegistry.register_value("news_rss", NewsRSSConnector) + assert ConnectorRegistry.contains("news_rss") + cls = ConnectorRegistry.get("news_rss") + assert cls.connector_id == "news_rss" + assert cls.auth_type == "local" + + +_SAMPLE_RSS = """\ + + + + Test Feed + + Article One + First article about AI advancements in 2026. + https://example.com/article-one + Wed, 01 Apr 2026 10:00:00 GMT + + + Article Two + Second article about quantum computing breakthroughs. + https://example.com/article-two + Wed, 01 Apr 2026 12:00:00 GMT + + + Article Three + Third article about open source projects. + https://example.com/article-three + Wed, 01 Apr 2026 14:00:00 GMT + + + +""" + +_SAMPLE_ATOM = """\ + + + Atom Feed + + Atom Entry + An atom feed entry about distributed systems. + + 2026-04-01T15:00:00Z + + +""" + + +@pytest.fixture() +def connector(tmp_path): + """NewsRSSConnector with fake config file.""" + import json + + from openjarvis.connectors.news_rss import NewsRSSConnector + + config_path = tmp_path / "news_rss.json" + config_path.write_text( + json.dumps({ + "feeds": [ + {"name": "Test Feed", "url": "https://example.com/rss.xml"}, + ] + }), + encoding="utf-8", + ) + return NewsRSSConnector(config_path=str(config_path)) + + +def test_is_connected(connector): + assert connector.is_connected() is True + + +def test_is_connected_no_file(tmp_path): + from openjarvis.connectors.news_rss import NewsRSSConnector + + c = NewsRSSConnector(config_path=str(tmp_path / "missing.json")) + assert c.is_connected() is False + + +def test_is_connected_empty_feeds(tmp_path): + from openjarvis.connectors.news_rss import NewsRSSConnector + + config_path = tmp_path / "news_rss.json" + config_path.write_text('{"feeds": []}', encoding="utf-8") + c = NewsRSSConnector(config_path=str(config_path)) + assert c.is_connected() is False + + +def test_sync_rss_feed(connector): + """Sync parses RSS XML and returns Documents.""" + with patch( + "openjarvis.connectors.news_rss._fetch_feed", + return_value=_SAMPLE_RSS, + ): + docs = list(connector.sync()) + + assert len(docs) == 3 + assert all(isinstance(d, Document) for d in docs) + + first = docs[0] + assert first.source == "news_rss" + assert first.doc_type == "article" + assert first.title == "Article One" + assert "AI advancements" in first.content + assert first.url == "https://example.com/article-one" + assert first.metadata["feed_name"] == "Test Feed" + + +def test_sync_atom_feed(tmp_path): + """Sync parses Atom XML and returns Documents.""" + import json + + from openjarvis.connectors.news_rss import NewsRSSConnector + + config_path = tmp_path / "news_rss.json" + config_path.write_text( + json.dumps({ + "feeds": [ + {"name": "Atom Feed", "url": "https://example.com/atom.xml"}, + ] + }), + encoding="utf-8", + ) + c = NewsRSSConnector(config_path=str(config_path)) + + with patch( + "openjarvis.connectors.news_rss._fetch_feed", + return_value=_SAMPLE_ATOM, + ): + docs = list(c.sync()) + + assert len(docs) == 1 + assert docs[0].title == "Atom Entry" + assert "distributed systems" in docs[0].content + + +def test_sync_filters_by_since(connector): + """Items older than `since` are excluded when date is parseable.""" + with patch( + "openjarvis.connectors.news_rss._fetch_feed", + return_value=_SAMPLE_RSS, + ): + # Only items after April 1 12:00 should come through + docs = list(connector.sync(since=datetime(2026, 4, 1, 11, 0, 0))) + + assert len(docs) == 2 + assert docs[0].title == "Article Two" + assert docs[1].title == "Article Three" + + +def test_disconnect(connector): + connector.disconnect() + assert connector.is_connected() is False diff --git a/tests/connectors/test_weather.py b/tests/connectors/test_weather.py new file mode 100644 index 00000000..142c27e3 --- /dev/null +++ b/tests/connectors/test_weather.py @@ -0,0 +1,97 @@ +"""Tests for WeatherConnector — OpenWeatherMap API.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +import pytest + +from openjarvis.connectors._stubs import Document +from openjarvis.core.registry import ConnectorRegistry + + +def test_weather_registered(): + """WeatherConnector is discoverable via ConnectorRegistry.""" + from openjarvis.connectors.weather import WeatherConnector + + ConnectorRegistry.register_value("weather", WeatherConnector) + assert ConnectorRegistry.contains("weather") + cls = ConnectorRegistry.get("weather") + assert cls.connector_id == "weather" + assert cls.display_name == "Weather" + assert cls.auth_type == "token" + + +_CURRENT_RESPONSE = { + "main": {"temp": 62.5, "humidity": 55}, + "weather": [{"description": "clear sky"}], + "wind": {"speed": 8.2}, +} + +_FORECAST_RESPONSE = { + "list": [ + { + "dt_txt": "2026-04-02 12:00:00", + "main": {"temp": 64.0}, + "weather": [{"description": "few clouds"}], + }, + { + "dt_txt": "2026-04-02 15:00:00", + "main": {"temp": 66.0}, + "weather": [{"description": "scattered clouds"}], + }, + ], +} + + +@pytest.fixture() +def connector(tmp_path): + """WeatherConnector with fake config file.""" + from openjarvis.connectors.weather import WeatherConnector + + config_path = tmp_path / "weather.json" + config_path.write_text( + '{"api_key": "fake-key", "location": "San Francisco,CA"}', + encoding="utf-8", + ) + return WeatherConnector(token_path=str(config_path)) + + +def test_is_connected(connector): + assert connector.is_connected() is True + + +def test_is_connected_no_file(tmp_path): + from openjarvis.connectors.weather import WeatherConnector + + c = WeatherConnector(token_path=str(tmp_path / "missing.json")) + assert c.is_connected() is False + + +def test_sync_yields_two_documents(connector): + """Sync returns one current weather and one forecast Document.""" + with patch( + "openjarvis.connectors.weather._weather_api_get", + side_effect=[_CURRENT_RESPONSE, _FORECAST_RESPONSE], + ): + docs = list(connector.sync()) + + assert len(docs) == 2 + assert all(isinstance(d, Document) for d in docs) + + current = docs[0] + assert current.source == "weather" + assert current.doc_type == "current" + assert "62.5" in current.content + assert "clear sky" in current.content + assert "55" in current.content + + forecast = docs[1] + assert forecast.doc_type == "forecast" + assert "64.0" in forecast.content + + +def test_disconnect(connector): + connector.disconnect() + assert connector.is_connected() is False