feat: priority-first digest prompt, 4 new connectors, honorific config

Prompt rewrite:
- Priority-first briefing structure (deadlines > schedule > messages)
- Interpret health trends, don't list raw numbers
- Connect related items across sections
- Configurable honorific (sir/ma'am/boss) from config.toml
- 250 word limit, no markdown, spoken-aloud format

New connectors:
- Weather (OpenWeatherMap API) — current conditions + 12h forecast
- GitHub Notifications — PR reviews, mentions, assignments
- Hacker News — top 5 stories with scores
- News/RSS — configurable feeds (Arxiv, NYT, WSJ, etc.)

Other:
- Gmail filters to category:primary (no promotions)
- Email body previews in digest data
- iMessage text content included
- WORLD section replaces MUSIC as default
- voice_speed plumbed through full pipeline
- 22 new tests for connectors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-04-02 16:14:21 -07:00
co-authored by Claude Opus 4.6
parent 4de2ffd720
commit 37c224e4b7
17 changed files with 1154 additions and 64 deletions
+16 -29
View File
@@ -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
+41 -26
View File
@@ -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,
}
),
)
+20
View File
@@ -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
@@ -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
+2 -2
View File
@@ -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
+91
View File
@@ -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
+158
View File
@@ -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: <rss><channel><item>
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: <feed><entry>
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
+135
View File
@@ -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
+2
View File
@@ -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(
+2
View File
@@ -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
+2
View File
@@ -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
+77 -6
View File
@@ -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,
}
+2 -1
View File
@@ -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:
@@ -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
+124
View File
@@ -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
+169
View File
@@ -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 = """\
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<item>
<title>Article One</title>
<description>First article about AI advancements in 2026.</description>
<link>https://example.com/article-one</link>
<pubDate>Wed, 01 Apr 2026 10:00:00 GMT</pubDate>
</item>
<item>
<title>Article Two</title>
<description>Second article about quantum computing breakthroughs.</description>
<link>https://example.com/article-two</link>
<pubDate>Wed, 01 Apr 2026 12:00:00 GMT</pubDate>
</item>
<item>
<title>Article Three</title>
<description>Third article about open source projects.</description>
<link>https://example.com/article-three</link>
<pubDate>Wed, 01 Apr 2026 14:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"""
_SAMPLE_ATOM = """\
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Feed</title>
<entry>
<title>Atom Entry</title>
<summary>An atom feed entry about distributed systems.</summary>
<link href="https://example.com/atom-entry" />
<updated>2026-04-01T15:00:00Z</updated>
</entry>
</feed>
"""
@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
+97
View File
@@ -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