feat: Morning Digest with Voice Mode + unified OAuth + Apple connectors (#174)

This commit is contained in:
Jon Saad-Falcon
2026-04-03 11:05:41 -07:00
committed by GitHub
parent 867a96fd6e
commit a009646071
122 changed files with 7496 additions and 15388 deletions
+4
View File
@@ -90,3 +90,7 @@ CLAUDE.md
research_mining_*
.python-version
src/openjarvis/channels/whatsapp_baileys_bridge/node_modules/
# SQLite in-memory artifacts
:memory:
MagicMock/
@@ -0,0 +1,30 @@
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.
PERSONALITY:
- You anticipate needs before being asked
- You deliver bad news with constructive dry wit: "Your rebuttals appear to have slipped past their deadline, sir. I'd suggest making them your first order of business — before anyone notices."
- Your humor is understated — a raised eyebrow in voice form
- You are calm under pressure and never flustered
- You treat the briefing as a conversation with someone you respect, not a status report
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
EMAIL TRIAGE:
- Important emails are from REAL PEOPLE (not automated senders, newsletters, or marketing)
- Prioritize emails that need a REPLY or DECISION, or contain a DEADLINE
- Skip promotional, automated, and notification emails entirely
- For important emails, mention the sender name and what they need
MESSAGE TRIAGE (iMessage, Slack, etc.):
- Highlight messages from key people and threads needing a reply
- Briefly acknowledge casual threads so the user knows you checked: "Your group chat has been lively but nothing requiring a response"
- Skip reactions, emoji-only messages, and automated notifications
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
- If a data source is disconnected or errored, skip it silently — do not mention connection issues
@@ -0,0 +1,11 @@
You are an AI assistant generating a daily briefing. Be clear, concise, and factual.
## Voice & Tone
- Straightforward and professional
- No personality or humor — just the facts
- Use plain language
## Structure
- Open with the date and a one-line summary
- Deliver each section with bullet points
- Close with a summary of action items
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,798 +0,0 @@
# Deep Research Agent v2 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `knowledge_sql` and `scan_chunks` tools to the DeepResearchAgent, rewrite the system prompt with query strategies, wire in the `think` tool, and validate with 4 test queries.
**Architecture:** Two new tool files following the existing `BaseTool`/`ToolSpec`/`ToolResult` pattern. The `knowledge_sql` tool runs read-only SQL against `KnowledgeStore._conn`. The `scan_chunks` tool pulls chunks from the store, batches them, and calls the LM to extract findings. The system prompt teaches the model when to use each tool. Everything wired together in `_launch_chat`.
**Tech Stack:** Python 3.10+, sqlite3, pytest
---
### Task 1: Create `knowledge_sql` tool
**Files:**
- Create: `src/openjarvis/tools/knowledge_sql.py`
- Create: `tests/tools/test_knowledge_sql.py`
- [ ] **Step 1: Write the test file**
Create `tests/tools/__init__.py` if it doesn't exist, then create `tests/tools/test_knowledge_sql.py`:
```python
"""Tests for KnowledgeSQLTool."""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.registry import ToolRegistry
@pytest.fixture()
def store(tmp_path: Path) -> KnowledgeStore:
ks = KnowledgeStore(str(tmp_path / "test.db"))
ks.store("Hello from Alice", source="imessage", author="Alice", doc_type="message")
ks.store("Hello from Alice again", source="imessage", author="Alice", doc_type="message")
ks.store("Meeting notes Q1", source="granola", author="Bob", doc_type="document")
ks.store("Email about Spain trip", source="gmail", author="Carol", doc_type="email")
return ks
def test_select_count(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(query="SELECT COUNT(*) as total FROM knowledge_chunks")
assert result.success
assert "4" in result.content
def test_group_by_author(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(
query="SELECT author, COUNT(*) as n FROM knowledge_chunks GROUP BY author ORDER BY n DESC"
)
assert result.success
assert "Alice" in result.content
assert "2" in result.content
def test_rejects_non_select(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(query="DELETE FROM knowledge_chunks")
assert not result.success
assert "read-only" in result.content.lower() or "SELECT" in result.content
def test_rejects_drop(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(query="DROP TABLE knowledge_chunks")
assert not result.success
def test_handles_bad_sql(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(query="SELECT * FROM nonexistent_table")
assert not result.success
def test_filter_by_source(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(
query="SELECT title, author FROM knowledge_chunks WHERE source = 'gmail'"
)
assert result.success
assert "Carol" in result.content
def test_registered() -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
ToolRegistry.register_value("knowledge_sql", KnowledgeSQLTool)
assert ToolRegistry.contains("knowledge_sql")
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/tools/test_knowledge_sql.py -v --tb=short
```
Expected: FAIL — `ModuleNotFoundError`
- [ ] **Step 3: Create the tool**
Create `src/openjarvis/tools/knowledge_sql.py`:
```python
"""KnowledgeSQLTool — read-only SQL queries against the KnowledgeStore.
Allows agents to run SELECT queries for aggregation, counting, ranking,
and filtering operations that BM25 search cannot handle.
"""
from __future__ import annotations
import sqlite3
from typing import Any, Optional
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
_MAX_ROWS = 50
_SCHEMA_DESCRIPTION = (
"Table: knowledge_chunks\n"
"Columns: id, content, source, doc_type, doc_id, title, author, "
"participants, timestamp, thread_id, url, metadata, chunk_index"
)
@ToolRegistry.register("knowledge_sql")
class KnowledgeSQLTool(BaseTool):
"""Run read-only SQL against the knowledge store for aggregation queries."""
tool_id = "knowledge_sql"
def __init__(self, store: Optional[KnowledgeStore] = None) -> None:
self._store = store
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="knowledge_sql",
description=(
"Run a read-only SQL SELECT query against the knowledge_chunks table. "
"Use for counting, ranking, aggregation, and filtering. "
f"{_SCHEMA_DESCRIPTION}"
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"SQL SELECT query. Only SELECT statements allowed. "
"Example: SELECT author, COUNT(*) as n FROM knowledge_chunks "
"WHERE source='imessage' GROUP BY author ORDER BY n DESC LIMIT 10"
),
},
},
"required": ["query"],
},
category="knowledge",
)
def execute(self, **params: Any) -> ToolResult:
if self._store is None:
return ToolResult(
tool_name="knowledge_sql",
content="No knowledge store configured.",
success=False,
)
query: str = params.get("query", "").strip()
if not query:
return ToolResult(
tool_name="knowledge_sql",
content="No query provided.",
success=False,
)
# Read-only enforcement
normalized = query.lstrip().upper()
if not normalized.startswith("SELECT"):
return ToolResult(
tool_name="knowledge_sql",
content="Only SELECT queries are allowed (read-only).",
success=False,
)
# Block dangerous keywords even in SELECT subqueries
for forbidden in ("DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE", "ATTACH"):
if forbidden in normalized:
return ToolResult(
tool_name="knowledge_sql",
content=f"Query contains forbidden keyword: {forbidden}. Only SELECT queries allowed.",
success=False,
)
try:
rows = self._store._conn.execute(query).fetchmany(_MAX_ROWS)
except sqlite3.OperationalError as exc:
return ToolResult(
tool_name="knowledge_sql",
content=f"SQL error: {exc}",
success=False,
)
if not rows:
return ToolResult(
tool_name="knowledge_sql",
content="Query returned no results.",
success=True,
metadata={"num_rows": 0},
)
# Format as table
columns = rows[0].keys()
lines = [" | ".join(columns)]
lines.append(" | ".join("---" for _ in columns))
for row in rows:
lines.append(" | ".join(str(row[c]) for c in columns))
return ToolResult(
tool_name="knowledge_sql",
content="\n".join(lines),
success=True,
metadata={"num_rows": len(rows)},
)
__all__ = ["KnowledgeSQLTool"]
```
- [ ] **Step 4: Ensure tests/tools/__init__.py exists**
```bash
touch tests/tools/__init__.py
```
- [ ] **Step 5: Run tests**
```bash
uv run pytest tests/tools/test_knowledge_sql.py -v --tb=short
```
Expected: 7/7 PASS.
- [ ] **Step 6: Commit**
```bash
git add src/openjarvis/tools/knowledge_sql.py tests/tools/__init__.py tests/tools/test_knowledge_sql.py
git commit -m "feat: add knowledge_sql tool for read-only SQL aggregation queries"
```
---
### Task 2: Create `scan_chunks` tool
**Files:**
- Create: `src/openjarvis/tools/scan_chunks.py`
- Create: `tests/tools/test_scan_chunks.py`
- [ ] **Step 1: Write the test file**
Create `tests/tools/test_scan_chunks.py`:
```python
"""Tests for ScanChunksTool."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
from unittest.mock import MagicMock
import pytest
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.registry import ToolRegistry
@pytest.fixture()
def store(tmp_path: Path) -> KnowledgeStore:
ks = KnowledgeStore(str(tmp_path / "test.db"))
ks.store("Met with Sequoia about Series A", source="granola", doc_type="document")
ks.store("Fundraising discussion with a]16z", source="granola", doc_type="document")
ks.store("Weekly standup notes", source="granola", doc_type="document")
ks.store("Trip to Spain with family", source="imessage", doc_type="message")
return ks
def _fake_engine() -> MagicMock:
"""Mock engine that echoes back a summary."""
engine = MagicMock()
engine.generate.return_value = {
"content": "Found: Sequoia Series A discussion, a16z fundraising",
"usage": {},
}
return engine
def test_scan_finds_semantic_matches(store: KnowledgeStore) -> None:
from openjarvis.tools.scan_chunks import ScanChunksTool
engine = _fake_engine()
tool = ScanChunksTool(store=store, engine=engine, model="test")
result = tool.execute(question="Which VCs have I spoken with?")
assert result.success
assert "Sequoia" in result.content or "Found" in result.content
# Engine should have been called with chunks
assert engine.generate.called
def test_scan_respects_source_filter(store: KnowledgeStore) -> None:
from openjarvis.tools.scan_chunks import ScanChunksTool
engine = _fake_engine()
tool = ScanChunksTool(store=store, engine=engine, model="test")
result = tool.execute(question="What trips?", source="imessage")
assert result.success
# Should only have passed imessage chunks to the engine
call_args = engine.generate.call_args
messages = call_args[0][0] if call_args[0] else call_args[1].get("messages", [])
# At least one call should contain "Spain"
all_content = str(messages)
assert "Spain" in all_content
def test_scan_empty_store(tmp_path: Path) -> None:
from openjarvis.tools.scan_chunks import ScanChunksTool
ks = KnowledgeStore(str(tmp_path / "empty.db"))
engine = _fake_engine()
tool = ScanChunksTool(store=ks, engine=engine, model="test")
result = tool.execute(question="Anything?")
assert result.success
assert "no chunks" in result.content.lower() or result.content == ""
def test_registered() -> None:
from openjarvis.tools.scan_chunks import ScanChunksTool
ToolRegistry.register_value("scan_chunks", ScanChunksTool)
assert ToolRegistry.contains("scan_chunks")
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/tools/test_scan_chunks.py -v --tb=short
```
Expected: FAIL — `ModuleNotFoundError`
- [ ] **Step 3: Create the tool**
Create `src/openjarvis/tools/scan_chunks.py`:
```python
"""ScanChunksTool — semantic grep via LM-powered chunk scanning.
Pulls chunks from the KnowledgeStore by filter, batches them, and asks the
LM to extract information relevant to a question. Catches semantic matches
that keyword-based BM25 search misses.
"""
from __future__ import annotations
from typing import Any, List, Optional
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import Message, Role, ToolResult
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool, ToolSpec
_DEFAULT_MAX_CHUNKS = 200
_DEFAULT_BATCH_SIZE = 20
@ToolRegistry.register("scan_chunks")
class ScanChunksTool(BaseTool):
"""Semantic grep — feeds chunks to the LM to find information BM25 misses."""
tool_id = "scan_chunks"
def __init__(
self,
store: Optional[KnowledgeStore] = None,
engine: Optional[InferenceEngine] = None,
model: str = "",
) -> None:
self._store = store
self._engine = engine
self._model = model
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="scan_chunks",
description=(
"Semantic search — feeds chunks from the knowledge store to an LM "
"that reads the actual text looking for relevant information. "
"Use when keyword search (knowledge_search) misses semantic matches "
"(e.g. searching for 'VCs' when text says 'fundraising round'). "
"Slower but catches what BM25 misses. "
"Filters: source, doc_type, since, until."
),
parameters={
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "What to look for in the chunks.",
},
"source": {
"type": "string",
"description": "Filter by source (e.g. 'granola', 'gmail').",
},
"doc_type": {
"type": "string",
"description": "Filter by doc type (e.g. 'document', 'email').",
},
"since": {
"type": "string",
"description": "Only chunks after this ISO timestamp.",
},
"until": {
"type": "string",
"description": "Only chunks before this ISO timestamp.",
},
"max_chunks": {
"type": "integer",
"description": f"Max chunks to scan (default {_DEFAULT_MAX_CHUNKS}).",
},
},
"required": ["question"],
},
category="knowledge",
)
def execute(self, **params: Any) -> ToolResult:
if self._store is None or self._engine is None:
return ToolResult(
tool_name="scan_chunks",
content="Scan tool not configured (missing store or engine).",
success=False,
)
question: str = params.get("question", "")
if not question:
return ToolResult(
tool_name="scan_chunks",
content="No question provided.",
success=False,
)
source: str = params.get("source", "")
doc_type: str = params.get("doc_type", "")
since: str = params.get("since", "")
until: str = params.get("until", "")
max_chunks: int = int(params.get("max_chunks", _DEFAULT_MAX_CHUNKS))
batch_size: int = _DEFAULT_BATCH_SIZE
# Pull chunks matching filters
where_clauses: List[str] = []
sql_params: List[Any] = []
if source:
where_clauses.append("source = ?")
sql_params.append(source)
if doc_type:
where_clauses.append("doc_type = ?")
sql_params.append(doc_type)
if since:
where_clauses.append("timestamp >= ?")
sql_params.append(since)
if until:
where_clauses.append("timestamp <= ?")
sql_params.append(until)
where = ""
if where_clauses:
where = "WHERE " + " AND ".join(where_clauses)
sql = f"SELECT content, source, title, author FROM knowledge_chunks {where} LIMIT ?"
sql_params.append(max_chunks)
rows = self._store._conn.execute(sql, sql_params).fetchall()
if not rows:
return ToolResult(
tool_name="scan_chunks",
content="No chunks found matching filters.",
success=True,
metadata={"chunks_scanned": 0},
)
# Batch and scan
findings: List[str] = []
for i in range(0, len(rows), batch_size):
batch = rows[i : i + batch_size]
batch_text = "\n\n---\n\n".join(
f"[{row['source']}] {row['title']} by {row['author']}:\n{row['content']}"
for row in batch
)
messages = [
Message(
role=Role.USER,
content=(
f"/no_think\n"
f"Extract any information relevant to this question: {question}\n\n"
f"If nothing is relevant, reply with exactly: NOTHING_RELEVANT\n\n"
f"Chunks:\n{batch_text}"
),
),
]
result = self._engine.generate(
messages, model=self._model, max_tokens=1024
)
content = result.get("content", "").strip()
if content and "NOTHING_RELEVANT" not in content:
findings.append(content)
if not findings:
return ToolResult(
tool_name="scan_chunks",
content=f"Scanned {len(rows)} chunks — no relevant information found.",
success=True,
metadata={"chunks_scanned": len(rows)},
)
return ToolResult(
tool_name="scan_chunks",
content="\n\n".join(findings),
success=True,
metadata={"chunks_scanned": len(rows), "batches_with_findings": len(findings)},
)
__all__ = ["ScanChunksTool"]
```
- [ ] **Step 4: Run tests**
```bash
uv run pytest tests/tools/test_scan_chunks.py -v --tb=short
```
Expected: 4/4 PASS.
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/tools/scan_chunks.py tests/tools/test_scan_chunks.py
git commit -m "feat: add scan_chunks tool for LM-powered semantic grep"
```
---
### Task 3: Rewrite system prompt + wire all tools + tune loop guard
**Files:**
- Modify: `src/openjarvis/agents/deep_research.py` (system prompt + max_turns)
- Modify: `src/openjarvis/cli/deep_research_setup_cmd.py` (`_launch_chat` function)
- [ ] **Step 1: Replace `DEEP_RESEARCH_SYSTEM_PROMPT` in `deep_research.py`**
Replace the entire `DEEP_RESEARCH_SYSTEM_PROMPT` string (from line 33 to line 55) with:
```python
DEEP_RESEARCH_SYSTEM_PROMPT = """\
/no_think
You are a deep research agent with access to a personal knowledge base \
containing emails, messages, meeting notes, documents, and notes.
## Your Tools
- **knowledge_search**: BM25 keyword search. Best for finding specific topics, \
names, or phrases. Use filters (source, author, since, until) to narrow results.
- **knowledge_sql**: Run read-only SQL against the knowledge_chunks table. \
Schema: knowledge_chunks(id, content, source, doc_type, doc_id, title, \
author, participants, timestamp, thread_id, url, metadata, chunk_index). \
Best for counting, ranking, and aggregation. \
Example: SELECT author, COUNT(*) as n FROM knowledge_chunks \
WHERE source='imessage' GROUP BY author ORDER BY n DESC LIMIT 10
- **scan_chunks**: Semantic search — feeds chunks to an LM that reads the \
actual text looking for relevant information. Use when keyword search returns \
nothing useful or when you need semantic matching (e.g. searching for 'VCs' \
when text says 'fundraising round'). Slower but catches what BM25 misses.
- **think**: Reasoning scratchpad. Use between searches to plan your next \
query, evaluate findings so far, and identify gaps.
## Strategy
1. Start with **think** to plan your approach — which tools suit this query?
2. For "who/what/how many" queries → use **knowledge_sql** with GROUP BY
3. For specific topics or names → use **knowledge_search**
4. If keyword search returns nothing useful → try **scan_chunks** with filters
5. Cross-reference across sources (emails, messages, meeting notes)
6. After gathering evidence → write a cited narrative report
## Citation Format
Cite sources as: [source] title -- author
End with a Sources section listing all referenced items.
## Rules
- Always cite your sources. Never present information without attribution.
- Make at least two searches to cross-reference across different sources.
- If a search returns no results, try a different tool or rephrase the query.
- Prefer specificity: filter by source, author, or date when appropriate.
- Your final answer should be a coherent narrative, not a list of raw results."""
```
- [ ] **Step 2: Update `_default_max_turns` from 5 to 8**
In `deep_research.py`, change:
```python
_default_max_turns = 8
```
- [ ] **Step 3: Update `_launch_chat` in `deep_research_setup_cmd.py` to wire all 4 tools**
Replace the tool setup section in `_launch_chat` (around lines 293-303):
```python
def _launch_chat(store: KnowledgeStore, console: Console) -> None:
"""Start an interactive Deep Research chat session."""
from openjarvis.agents.deep_research import DeepResearchAgent
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
from openjarvis.tools.scan_chunks import ScanChunksTool
from openjarvis.tools.think import ThinkTool
console.print("\n[bold]Setting up Deep Research agent...[/bold]")
# Engine
engine = OllamaEngine()
if not engine.health():
console.print(
"[red]Ollama is not running.[/red] Start it with: "
"[bold]ollama serve[/bold]"
)
return
models = engine.list_models()
if _OLLAMA_MODEL not in models and f"{_OLLAMA_MODEL}:latest" not in models:
base_name = _OLLAMA_MODEL.split(":")[0]
matching = [m for m in models if m.startswith(base_name)]
if not matching:
console.print(
f"[yellow]Model {_OLLAMA_MODEL} not found.[/yellow] "
f"Pull it with: [bold]ollama pull {_OLLAMA_MODEL}[/bold]"
)
return
# Tools
retriever = TwoStageRetriever(store)
tools = [
KnowledgeSearchTool(retriever=retriever),
KnowledgeSQLTool(store=store),
ScanChunksTool(store=store, engine=engine, model=_OLLAMA_MODEL),
ThinkTool(),
]
# Agent
agent = DeepResearchAgent(
engine=engine,
model=_OLLAMA_MODEL,
tools=tools,
interactive=True,
)
console.print(
f"[green]Ready![/green] Using [bold]{_OLLAMA_MODEL}[/bold] via Ollama.\n"
"Tools: knowledge_search, knowledge_sql, scan_chunks, think\n"
"Type your research question. Type [bold]/quit[/bold] to exit.\n"
)
# REPL
while True:
try:
query = console.input("[bold blue]research>[/bold blue] ").strip()
except (EOFError, KeyboardInterrupt):
break
if not query:
continue
if query.lower() in ("/quit", "/exit", "quit", "exit"):
break
try:
result = agent.run(query)
console.print(f"\n{result.content}\n")
if result.metadata and result.metadata.get("sources"):
console.print("[dim]Sources:[/dim]")
for s in result.metadata["sources"]:
console.print(f" [dim]- {s}[/dim]")
console.print()
except Exception as exc: # noqa: BLE001
console.print(f"[red]Error: {exc}[/red]\n")
```
- [ ] **Step 4: Run all tests**
```bash
uv run pytest tests/agents/test_deep_research.py tests/agents/test_deep_research_integration.py tests/tools/test_knowledge_sql.py tests/tools/test_scan_chunks.py -v --tb=short
```
Expected: All PASS.
- [ ] **Step 5: Lint**
```bash
uv run ruff check src/openjarvis/agents/deep_research.py src/openjarvis/cli/deep_research_setup_cmd.py src/openjarvis/tools/knowledge_sql.py src/openjarvis/tools/scan_chunks.py
```
- [ ] **Step 6: Commit**
```bash
git add src/openjarvis/agents/deep_research.py src/openjarvis/cli/deep_research_setup_cmd.py
git commit -m "feat: rewrite DeepResearch system prompt, wire SQL + scan + think tools, increase max_turns to 8"
```
---
### Task 4: Evaluate with 4 test queries
**Files:** None (manual testing)
- [ ] **Step 1: Run the 4 test queries**
```python
import time
from openjarvis.agents.deep_research import DeepResearchAgent
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
from openjarvis.tools.scan_chunks import ScanChunksTool
from openjarvis.tools.think import ThinkTool
store = KnowledgeStore()
engine = OllamaEngine()
retriever = TwoStageRetriever(store)
tools = [
KnowledgeSearchTool(retriever=retriever),
KnowledgeSQLTool(store=store),
ScanChunksTool(store=store, engine=engine, model="qwen3.5:9b"),
ThinkTool(),
]
agent = DeepResearchAgent(engine=engine, model="qwen3.5:9b", tools=tools, max_turns=8)
queries = [
"When was my most recent trip to Spain?",
"Which VCs have I spoken with since 2023?",
"Who are the 10 people I have spoken with the most over text?",
"What meetings take up most of my time based on my calendar and meeting logs?",
]
for i, query in enumerate(queries, 1):
print(f"QUERY {i}: {query}")
t0 = time.time()
result = agent.run(query)
print(result.content[:3000])
print(f"[Turns: {result.turns}, Tools: {len(result.tool_results)}, Time: {time.time()-t0:.0f}s]\n")
```
- [ ] **Step 2: Compare to v1 results**
v1 returned empty or "no data found" for all 4 queries. Success = at least 3 of 4 produce substantive answers.
- [ ] **Step 3: Push**
```bash
git push origin feat/deep-research-setup
```
@@ -1,361 +0,0 @@
# Deep Research Phase 2B-i: Connector Management API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add FastAPI endpoints for connector management so the desktop wizard and CLI can list sources, initiate OAuth, check sync status, and trigger syncs — all via HTTP.
**Architecture:** A new `connectors` API router exposes endpoints for listing available connectors, initiating auth flows, checking connection status, triggering syncs, and streaming sync progress. The router wraps the existing `ConnectorRegistry`, `SyncEngine`, and `KnowledgeStore` from Phase 1. An OAuth callback server handles the browser redirect during auth flows.
**Tech Stack:** Python 3.10+, FastAPI (existing server infrastructure), pytest + httpx (test client)
**Spec:** `docs/superpowers/specs/2026-03-25-deep-research-setup-design.md` — Sections 4, 10
**Depends on:** Phase 1 (ConnectorRegistry, BaseConnector, SyncEngine, KnowledgeStore)
---
## File Structure
```
src/openjarvis/server/
├── connectors_router.py # FastAPI router: /v1/connectors/* endpoints
tests/server/
├── test_connectors_router.py # API endpoint tests
```
---
### Task 1: Connectors API Router
**Files:**
- Create: `src/openjarvis/server/connectors_router.py`
- Create: `tests/server/test_connectors_router.py`
- [ ] **Step 1: Write failing tests**
Create `tests/server/test_connectors_router.py`:
```python
"""Tests for the /v1/connectors API endpoints."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.server.connectors_router import create_connectors_router
@pytest.fixture
def app():
"""Create a minimal FastAPI app with the connectors router."""
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
except ImportError:
pytest.skip("fastapi not installed")
app = FastAPI()
router = create_connectors_router()
app.include_router(router, prefix="/v1")
return TestClient(app)
def test_list_connectors(app) -> None:
"""GET /v1/connectors returns list of available connectors."""
resp = app.get("/v1/connectors")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
# Should have at least obsidian (always available)
ids = [c["connector_id"] for c in data]
assert "obsidian" in ids
def test_connector_detail(app) -> None:
"""GET /v1/connectors/{id} returns connector info."""
resp = app.get("/v1/connectors/obsidian")
assert resp.status_code == 200
data = resp.json()
assert data["connector_id"] == "obsidian"
assert data["auth_type"] == "filesystem"
assert "connected" in data
def test_connector_not_found(app) -> None:
"""GET /v1/connectors/{id} returns 404 for unknown."""
resp = app.get("/v1/connectors/nonexistent")
assert resp.status_code == 404
def test_connect_obsidian(app, tmp_path: Path) -> None:
"""POST /v1/connectors/obsidian/connect with path."""
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("# Test")
resp = app.post(
"/v1/connectors/obsidian/connect",
json={"path": str(vault)},
)
assert resp.status_code == 200
assert resp.json()["connected"] is True
def test_disconnect(app) -> None:
"""POST /v1/connectors/obsidian/disconnect."""
resp = app.post("/v1/connectors/obsidian/disconnect")
assert resp.status_code == 200
def test_sync_status(app) -> None:
"""GET /v1/connectors/obsidian/sync returns sync status."""
resp = app.get("/v1/connectors/obsidian/sync")
assert resp.status_code == 200
data = resp.json()
assert "state" in data
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/server/test_connectors_router.py -v`
Expected: FAIL — `ModuleNotFoundError`
- [ ] **Step 3: Implement connectors router**
Create `src/openjarvis/server/connectors_router.py`:
```python
"""FastAPI router for connector management: /v1/connectors/*
Exposes endpoints for listing connectors, connecting/disconnecting,
checking status, and triggering syncs. Used by the desktop wizard
and CLI.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from openjarvis.connectors._stubs import SyncStatus
from openjarvis.core.registry import ConnectorRegistry
logger = logging.getLogger(__name__)
# Connector instances cache (keyed by connector_id)
_instances: Dict[str, Any] = {}
def _get_or_create(connector_id: str, **kwargs: Any) -> Any:
"""Get a cached connector instance or create one."""
if connector_id not in _instances:
cls = ConnectorRegistry.get(connector_id)
_instances[connector_id] = cls(**kwargs)
return _instances[connector_id]
def create_connectors_router():
"""Factory that returns a FastAPI APIRouter for /v1/connectors.
Separated from module-level to allow lazy import of FastAPI.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(tags=["connectors"])
class ConnectRequest(BaseModel):
path: Optional[str] = None
token: Optional[str] = None
code: Optional[str] = None
email: Optional[str] = None
password: Optional[str] = None
class ConnectorInfo(BaseModel):
connector_id: str
display_name: str
auth_type: str
connected: bool
# Import connectors to trigger registration
import openjarvis.connectors # noqa: F401
@router.get("/connectors")
def list_connectors() -> List[Dict[str, Any]]:
"""List all available data source connectors."""
results = []
for key in sorted(ConnectorRegistry.keys()):
cls = ConnectorRegistry.get(key)
try:
inst = _get_or_create(key)
connected = inst.is_connected()
except Exception:
connected = False
results.append(
{
"connector_id": key,
"display_name": getattr(
cls, "display_name", key
),
"auth_type": getattr(cls, "auth_type", "unknown"),
"connected": connected,
}
)
return results
@router.get("/connectors/{connector_id}")
def get_connector(connector_id: str) -> Dict[str, Any]:
"""Get details for a specific connector."""
if not ConnectorRegistry.contains(connector_id):
raise HTTPException(
status_code=404,
detail=f"Connector '{connector_id}' not found",
)
cls = ConnectorRegistry.get(connector_id)
try:
inst = _get_or_create(connector_id)
connected = inst.is_connected()
except Exception:
connected = False
auth_url = ""
try:
inst = _get_or_create(connector_id)
auth_url = inst.auth_url()
except NotImplementedError:
pass
mcp_tools = []
try:
inst = _get_or_create(connector_id)
mcp_tools = [t.name for t in inst.mcp_tools()]
except Exception:
pass
return {
"connector_id": connector_id,
"display_name": getattr(cls, "display_name", connector_id),
"auth_type": getattr(cls, "auth_type", "unknown"),
"connected": connected,
"auth_url": auth_url,
"mcp_tools": mcp_tools,
}
@router.post("/connectors/{connector_id}/connect")
def connect_source(
connector_id: str, req: ConnectRequest
) -> Dict[str, Any]:
"""Connect a data source."""
if not ConnectorRegistry.contains(connector_id):
raise HTTPException(
status_code=404,
detail=f"Connector '{connector_id}' not found",
)
cls = ConnectorRegistry.get(connector_id)
auth_type = getattr(cls, "auth_type", "")
if auth_type == "filesystem" and req.path:
inst = cls(vault_path=req.path)
_instances[connector_id] = inst
return {"connected": inst.is_connected()}
if req.token:
inst = cls(token=req.token)
_instances[connector_id] = inst
return {"connected": inst.is_connected()}
if req.code:
inst = _get_or_create(connector_id)
inst.handle_callback(req.code)
return {"connected": inst.is_connected()}
if req.email and req.password:
inst = cls(
email_address=req.email,
app_password=req.password,
)
_instances[connector_id] = inst
return {"connected": inst.is_connected()}
raise HTTPException(
status_code=400,
detail="Provide path, token, code, or email+password",
)
@router.post("/connectors/{connector_id}/disconnect")
def disconnect_source(connector_id: str) -> Dict[str, Any]:
"""Disconnect a data source."""
if not ConnectorRegistry.contains(connector_id):
raise HTTPException(
status_code=404,
detail=f"Connector '{connector_id}' not found",
)
inst = _get_or_create(connector_id)
inst.disconnect()
return {"disconnected": True}
@router.get("/connectors/{connector_id}/sync")
def get_sync_status(connector_id: str) -> Dict[str, Any]:
"""Get sync status for a connector."""
if not ConnectorRegistry.contains(connector_id):
raise HTTPException(
status_code=404,
detail=f"Connector '{connector_id}' not found",
)
inst = _get_or_create(connector_id)
status = inst.sync_status()
return {
"state": status.state,
"items_synced": status.items_synced,
"items_total": status.items_total,
"last_sync": (
status.last_sync.isoformat() if status.last_sync else None
),
"error": status.error,
}
return router
```
- [ ] **Step 4: Run tests**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/server/test_connectors_router.py -v`
Expected: All 6 tests PASS.
- [ ] **Step 5: Run linter**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run ruff check src/openjarvis/server/connectors_router.py`
Expected: No errors.
- [ ] **Step 6: Commit**
```bash
git add src/openjarvis/server/connectors_router.py tests/server/test_connectors_router.py
git commit -m "feat: add /v1/connectors API router for connector management"
```
---
## Post-Plan Notes
**What this plan produces:**
- `GET /v1/connectors` — list all available connectors with connection status
- `GET /v1/connectors/{id}` — connector detail with auth_url and MCP tools
- `POST /v1/connectors/{id}/connect` — connect with path, token, code, or email+password
- `POST /v1/connectors/{id}/disconnect` — disconnect a source
- `GET /v1/connectors/{id}/sync` — sync status (state, items_synced, items_total, error)
**Phase 2B-ii (Frontend wizard)** will consume these endpoints to build:
- Source picker grid (calls `GET /v1/connectors`)
- Per-source OAuth flow (calls `GET /v1/connectors/{id}` for auth_url, then `POST .../connect`)
- Ingest progress dashboard (polls `GET /v1/connectors/{id}/sync`)
- New Tauri commands wrapping these endpoints
**Phase 2B-ii is a TypeScript/React effort** — separate plan needed for the frontend components, Tauri commands, and wizard UI.
@@ -1,774 +0,0 @@
# Deep Research Phase 2B-ii: Desktop Setup Wizard UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the "Apple-like magic" setup wizard in the Tauri desktop app — a beautiful multi-step onboarding flow that guides users through connecting their data sources, with product logos, progress bars, and a premium feel.
**Architecture:** Extends the existing `SetupScreen.tsx` in `frontend/src/components/` with new wizard steps after the engine boot completes. Uses the `/v1/connectors/*` API endpoints (Phase 2B-i) for backend communication. New components: SourcePicker (grid of connector cards), SourceConnectWizard (per-source auth flow), IngestDashboard (live sync progress), and ReadyScreen (first query suggestions). Follows existing patterns: Tailwind + shadcn/ui components, Zustand for state, React Router for navigation.
**Tech Stack:** TypeScript, React 19, Tailwind CSS 4.2, shadcn/ui, Zustand 5, Vite 6, Tauri 2
**Spec:** `docs/superpowers/specs/2026-03-25-deep-research-setup-design.md` — Section 4 (Setup Wizard)
**Depends on:** Phase 2B-i (connector API endpoints)
---
## File Structure
```
frontend/src/
├── components/
│ ├── setup/
│ │ ├── SetupWizard.tsx # Main wizard orchestrator (steps 3-6)
│ │ ├── SourcePicker.tsx # Step 3: grid of source cards
│ │ ├── SourceConnectFlow.tsx # Step 4: guided per-source auth
│ │ ├── IngestDashboard.tsx # Step 5: live sync progress
│ │ └── ReadyScreen.tsx # Step 6: first query suggestions
│ ├── SetupScreen.tsx # (modify) Add wizard after boot
├── lib/
│ ├── connectors-api.ts # API client for /v1/connectors/*
│ └── store.ts # (modify) Add connector state slice
├── types/
│ └── connectors.ts # TypeScript interfaces
```
---
### Task 1: TypeScript Types + API Client
**Files:**
- Create: `frontend/src/types/connectors.ts`
- Create: `frontend/src/lib/connectors-api.ts`
- [ ] **Step 1: Create connector types**
Create `frontend/src/types/connectors.ts`:
```typescript
export interface ConnectorInfo {
connector_id: string;
display_name: string;
auth_type: "oauth" | "local" | "bridge" | "filesystem";
connected: boolean;
auth_url?: string;
mcp_tools?: string[];
}
export interface SyncStatus {
state: "idle" | "syncing" | "paused" | "error";
items_synced: number;
items_total: number;
last_sync: string | null;
error: string | null;
}
export interface ConnectRequest {
path?: string;
token?: string;
code?: string;
email?: string;
password?: string;
}
export type WizardStep = "pick" | "connect" | "ingest" | "ready";
// Source card metadata for the picker UI
export interface SourceCard {
connector_id: string;
display_name: string;
auth_type: string;
category: "communication" | "documents" | "pim";
icon: string; // Lucide icon name
color: string; // Tailwind color class
description: string;
}
export const SOURCE_CATALOG: SourceCard[] = [
// Communication
{ connector_id: "gmail", display_name: "Gmail", auth_type: "oauth", category: "communication", icon: "Mail", color: "text-red-400", description: "Email messages and threads" },
{ connector_id: "gmail_imap", display_name: "Gmail (IMAP)", auth_type: "oauth", category: "communication", icon: "Mail", color: "text-red-400", description: "Email via app password" },
{ connector_id: "slack", display_name: "Slack", auth_type: "oauth", category: "communication", icon: "Hash", color: "text-purple-400", description: "Channel messages and threads" },
{ connector_id: "imessage", display_name: "iMessage", auth_type: "local", category: "communication", icon: "MessageSquare", color: "text-green-400", description: "macOS Messages history" },
// Documents
{ connector_id: "gdrive", display_name: "Google Drive", auth_type: "oauth", category: "documents", icon: "FolderOpen", color: "text-blue-400", description: "Docs, Sheets, and files" },
{ connector_id: "notion", display_name: "Notion", auth_type: "oauth", category: "documents", icon: "FileText", color: "text-gray-300", description: "Pages and databases" },
{ connector_id: "obsidian", display_name: "Obsidian", auth_type: "filesystem", category: "documents", icon: "Diamond", color: "text-violet-400", description: "Markdown vault" },
{ connector_id: "granola", display_name: "Granola", auth_type: "oauth", category: "documents", icon: "Mic", color: "text-amber-400", description: "AI meeting notes" },
// PIM
{ connector_id: "gcalendar", display_name: "Calendar", auth_type: "oauth", category: "pim", icon: "Calendar", color: "text-blue-400", description: "Events and meetings" },
{ connector_id: "gcontacts", display_name: "Contacts", auth_type: "oauth", category: "pim", icon: "Users", color: "text-blue-400", description: "People and contact info" },
];
```
- [ ] **Step 2: Create API client**
Create `frontend/src/lib/connectors-api.ts`:
```typescript
import { getBase } from "./api";
import type { ConnectorInfo, ConnectRequest, SyncStatus } from "../types/connectors";
export async function listConnectors(): Promise<ConnectorInfo[]> {
const resp = await fetch(`${getBase()}/v1/connectors`);
if (!resp.ok) throw new Error(`Failed to list connectors: ${resp.status}`);
return resp.json();
}
export async function getConnector(id: string): Promise<ConnectorInfo> {
const resp = await fetch(`${getBase()}/v1/connectors/${id}`);
if (!resp.ok) throw new Error(`Connector not found: ${id}`);
return resp.json();
}
export async function connectSource(id: string, req: ConnectRequest): Promise<{ connected: boolean }> {
const resp = await fetch(`${getBase()}/v1/connectors/${id}/connect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
if (!resp.ok) throw new Error(`Failed to connect: ${resp.status}`);
return resp.json();
}
export async function disconnectSource(id: string): Promise<void> {
await fetch(`${getBase()}/v1/connectors/${id}/disconnect`, { method: "POST" });
}
export async function getSyncStatus(id: string): Promise<SyncStatus> {
const resp = await fetch(`${getBase()}/v1/connectors/${id}/sync`);
if (!resp.ok) throw new Error(`Failed to get sync status: ${id}`);
return resp.json();
}
```
- [ ] **Step 3: Commit**
```bash
cd frontend && git add src/types/connectors.ts src/lib/connectors-api.ts
git commit -m "feat: add connector TypeScript types and API client"
```
---
### Task 2: SourcePicker Component (Step 3)
**Files:**
- Create: `frontend/src/components/setup/SourcePicker.tsx`
- [ ] **Step 1: Implement SourcePicker**
Create `frontend/src/components/setup/SourcePicker.tsx`:
```tsx
import { useState } from "react";
import {
Mail, Hash, MessageSquare, FolderOpen, FileText,
Diamond, Mic, Calendar, Users, Check,
} from "lucide-react";
import { SOURCE_CATALOG, type SourceCard } from "../../types/connectors";
const ICON_MAP: Record<string, React.ElementType> = {
Mail, Hash, MessageSquare, FolderOpen, FileText,
Diamond, Mic, Calendar, Users,
};
const CATEGORIES = [
{ key: "communication", label: "Communication" },
{ key: "documents", label: "Documents" },
{ key: "pim", label: "Personal Info" },
] as const;
interface Props {
onContinue: (selectedIds: string[]) => void;
}
export function SourcePicker({ onContinue }: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="flex flex-col items-center gap-8 px-8 py-12 max-w-4xl mx-auto">
<div className="text-center">
<h2 className="text-2xl font-semibold" style={{ color: "var(--color-text)" }}>
Connect Your Data
</h2>
<p className="mt-2 text-sm opacity-60">
Choose the sources you want to search across. You can always add more later.
</p>
</div>
{CATEGORIES.map(({ key, label }) => (
<div key={key} className="w-full">
<h3 className="text-xs uppercase tracking-wider opacity-40 mb-3 px-1">
{label}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{SOURCE_CATALOG.filter((s) => s.category === key).map((source) => {
const Icon = ICON_MAP[source.icon] ?? FileText;
const isSelected = selected.has(source.connector_id);
return (
<button
key={source.connector_id}
onClick={() => toggle(source.connector_id)}
className={`
relative flex flex-col items-center gap-2 p-4 rounded-xl
border transition-all duration-150 cursor-pointer
${isSelected
? "border-blue-500/60 bg-blue-500/10"
: "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/8"
}
`}
>
{isSelected && (
<div className="absolute top-2 right-2">
<Check size={14} className="text-blue-400" />
</div>
)}
<Icon size={24} className={source.color} />
<span className="text-sm font-medium">{source.display_name}</span>
<span className="text-xs opacity-40 text-center">{source.description}</span>
</button>
);
})}
</div>
</div>
))}
<button
onClick={() => onContinue(Array.from(selected))}
disabled={selected.size === 0}
className={`
mt-4 px-8 py-3 rounded-xl font-medium transition-all
${selected.size > 0
? "bg-blue-500 text-white hover:bg-blue-400"
: "bg-white/10 text-white/30 cursor-not-allowed"
}
`}
>
Connect {selected.size > 0 ? `${selected.size} source${selected.size > 1 ? "s" : ""}` : "sources"}
</button>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
cd frontend && git add src/components/setup/SourcePicker.tsx
git commit -m "feat: add SourcePicker component with category grid and toggle selection"
```
---
### Task 3: SourceConnectFlow Component (Step 4)
**Files:**
- Create: `frontend/src/components/setup/SourceConnectFlow.tsx`
- [ ] **Step 1: Implement SourceConnectFlow**
Create `frontend/src/components/setup/SourceConnectFlow.tsx`:
```tsx
import { useState } from "react";
import { Check, ExternalLink, Loader2, X, FolderOpen } from "lucide-react";
import { SOURCE_CATALOG } from "../../types/connectors";
import { connectSource, getConnector } from "../../lib/connectors-api";
interface Props {
selectedIds: string[];
onComplete: () => void;
}
type SourceState = "pending" | "connecting" | "connected" | "skipped" | "error";
export function SourceConnectFlow({ selectedIds, onComplete }: Props) {
const [currentIndex, setCurrentIndex] = useState(0);
const [states, setStates] = useState<Record<string, SourceState>>(
Object.fromEntries(selectedIds.map((id) => [id, "pending"]))
);
const [error, setError] = useState("");
const [inputValue, setInputValue] = useState("");
const currentId = selectedIds[currentIndex];
const source = SOURCE_CATALOG.find((s) => s.connector_id === currentId);
const isLast = currentIndex >= selectedIds.length - 1;
const allDone = currentIndex >= selectedIds.length;
const advance = () => {
if (isLast) {
onComplete();
} else {
setCurrentIndex((i) => i + 1);
setInputValue("");
setError("");
}
};
const skip = () => {
setStates((s) => ({ ...s, [currentId]: "skipped" }));
advance();
};
const handleConnect = async () => {
if (!currentId || !source) return;
setStates((s) => ({ ...s, [currentId]: "connecting" }));
setError("");
try {
if (source.auth_type === "filesystem") {
const result = await connectSource(currentId, { path: inputValue });
if (result.connected) {
setStates((s) => ({ ...s, [currentId]: "connected" }));
setTimeout(advance, 800);
} else {
throw new Error("Could not access that path");
}
} else if (source.auth_type === "oauth") {
// Get auth URL and open in browser
const info = await getConnector(currentId);
if (info.auth_url) {
window.open(info.auth_url, "_blank");
}
// For token/code entry
if (inputValue) {
const result = await connectSource(currentId, { token: inputValue });
if (result.connected) {
setStates((s) => ({ ...s, [currentId]: "connected" }));
setTimeout(advance, 800);
}
}
} else if (source.auth_type === "local") {
// Local access (iMessage, Apple Notes) — just check
const result = await connectSource(currentId, {});
if (result.connected) {
setStates((s) => ({ ...s, [currentId]: "connected" }));
setTimeout(advance, 800);
} else {
throw new Error("Permission not granted. Check Full Disk Access in System Preferences.");
}
}
} catch (err: any) {
setError(err.message || "Connection failed");
setStates((s) => ({ ...s, [currentId]: "error" }));
}
};
if (allDone) {
onComplete();
return null;
}
return (
<div className="flex gap-8 px-8 py-12 max-w-4xl mx-auto">
{/* Sidebar checklist */}
<div className="w-48 flex-shrink-0">
<h3 className="text-xs uppercase tracking-wider opacity-40 mb-4">Progress</h3>
{selectedIds.map((id, i) => {
const s = SOURCE_CATALOG.find((x) => x.connector_id === id);
const state = states[id];
return (
<div key={id} className={`flex items-center gap-2 py-2 text-sm ${i === currentIndex ? "opacity-100" : "opacity-40"}`}>
{state === "connected" && <Check size={14} className="text-green-400" />}
{state === "skipped" && <X size={14} className="text-gray-500" />}
{state === "connecting" && <Loader2 size={14} className="animate-spin text-blue-400" />}
{(state === "pending" || state === "error") && <div className="w-3.5 h-3.5 rounded-full border border-white/20" />}
<span>{s?.display_name ?? id}</span>
</div>
);
})}
</div>
{/* Main content */}
<div className="flex-1">
<h2 className="text-xl font-semibold mb-2">
Connect {source?.display_name}
</h2>
{source?.auth_type === "filesystem" && (
<div className="mt-6">
<p className="text-sm opacity-60 mb-4">Enter the path to your vault or folder:</p>
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="/path/to/vault"
className="flex-1 px-4 py-2 rounded-lg bg-white/5 border border-white/10 text-sm"
/>
<button onClick={handleConnect} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-400">
Connect
</button>
</div>
</div>
)}
{source?.auth_type === "oauth" && (
<div className="mt-6">
<p className="text-sm opacity-60 mb-4">
We'll open {source.display_name} in your browser for authorization. After authorizing, paste the token or code below.
</p>
<button
onClick={handleConnect}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-400 mb-4"
>
<ExternalLink size={14} />
Open {source.display_name}
</button>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Paste token or code here"
className="w-full px-4 py-2 rounded-lg bg-white/5 border border-white/10 text-sm"
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
/>
</div>
)}
{source?.auth_type === "local" && (
<div className="mt-6">
<p className="text-sm opacity-60 mb-4">
This requires Full Disk Access on macOS. Grant access in System Preferences Privacy & Security Full Disk Access.
</p>
<button onClick={handleConnect} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-400">
Check Access
</button>
</div>
)}
{error && (
<p className="mt-4 text-sm text-red-400">{error}</p>
)}
<button onClick={skip} className="mt-6 text-sm opacity-40 hover:opacity-60 transition-opacity">
Skip for now
</button>
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
cd frontend && git add src/components/setup/SourceConnectFlow.tsx
git commit -m "feat: add SourceConnectFlow with per-source guided auth wizard"
```
---
### Task 4: IngestDashboard + ReadyScreen (Steps 5-6)
**Files:**
- Create: `frontend/src/components/setup/IngestDashboard.tsx`
- Create: `frontend/src/components/setup/ReadyScreen.tsx`
- [ ] **Step 1: Implement IngestDashboard**
Create `frontend/src/components/setup/IngestDashboard.tsx`:
```tsx
import { useEffect, useState } from "react";
import { Loader2, Check } from "lucide-react";
import { getSyncStatus } from "../../lib/connectors-api";
import { SOURCE_CATALOG, type SyncStatus } from "../../types/connectors";
interface Props {
connectedIds: string[];
onReady: () => void;
}
export function IngestDashboard({ connectedIds, onReady }: Props) {
const [statuses, setStatuses] = useState<Record<string, SyncStatus>>({});
useEffect(() => {
if (connectedIds.length === 0) {
onReady();
return;
}
const poll = setInterval(async () => {
const updates: Record<string, SyncStatus> = {};
for (const id of connectedIds) {
try {
updates[id] = await getSyncStatus(id);
} catch {
updates[id] = { state: "error", items_synced: 0, items_total: 0, last_sync: null, error: "Failed to fetch status" };
}
}
setStatuses(updates);
// Check if all done
const allIdle = Object.values(updates).every((s) => s.state === "idle" && s.items_synced > 0);
if (allIdle) {
clearInterval(poll);
}
}, 2000);
return () => clearInterval(poll);
}, [connectedIds]);
return (
<div className="flex flex-col items-center gap-8 px-8 py-12 max-w-3xl mx-auto">
<div className="text-center">
<h2 className="text-2xl font-semibold" style={{ color: "var(--color-text)" }}>
Indexing Your Data
</h2>
<p className="mt-2 text-sm opacity-60">
Your data is being synced and indexed. You can start asking questions now.
</p>
</div>
<div className="w-full space-y-4">
{connectedIds.map((id) => {
const source = SOURCE_CATALOG.find((s) => s.connector_id === id);
const status = statuses[id];
const synced = status?.items_synced ?? 0;
const total = status?.items_total ?? 0;
const pct = total > 0 ? Math.round((synced / total) * 100) : 0;
const isDone = status?.state === "idle" && synced > 0;
return (
<div key={id} className="flex items-center gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
<div className="w-8">
{isDone ? (
<Check size={18} className="text-green-400" />
) : (
<Loader2 size={18} className="animate-spin text-blue-400" />
)}
</div>
<div className="flex-1">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{source?.display_name ?? id}</span>
<span className="opacity-40">
{isDone ? `${synced.toLocaleString()} items` : total > 0 ? `${synced.toLocaleString()} / ${total.toLocaleString()}` : "Starting..."}
</span>
</div>
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-500"
style={{ width: `${isDone ? 100 : pct}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
<button
onClick={onReady}
className="mt-4 px-8 py-3 bg-blue-500 text-white rounded-xl font-medium hover:bg-blue-400 transition-all"
>
Start Researching
</button>
</div>
);
}
```
- [ ] **Step 2: Implement ReadyScreen**
Create `frontend/src/components/setup/ReadyScreen.tsx`:
```tsx
import { Sparkles } from "lucide-react";
interface Props {
connectedSources: string[];
onStart: (query?: string) => void;
}
const SUGGESTIONS = [
"What were the key decisions from last week's team threads?",
"Find the proposal doc shared about the roadmap",
"Summarize my unread emails from today",
"What meetings do I have this week?",
"What topics came up in recent meetings?",
];
export function ReadyScreen({ connectedSources, onStart }: Props) {
return (
<div className="flex flex-col items-center gap-8 px-8 py-16 max-w-2xl mx-auto text-center">
<div className="w-16 h-16 rounded-2xl bg-green-500/10 flex items-center justify-center">
<Sparkles size={32} className="text-green-400" />
</div>
<div>
<h2 className="text-2xl font-semibold" style={{ color: "var(--color-text)" }}>
You're All Set!
</h2>
<p className="mt-2 text-sm opacity-60">
{connectedSources.length} source{connectedSources.length !== 1 ? "s" : ""} connected and indexed. Ask anything across your data.
</p>
</div>
<div className="w-full space-y-2">
<p className="text-xs uppercase tracking-wider opacity-40 mb-3">Try asking...</p>
{SUGGESTIONS.slice(0, 3).map((q) => (
<button
key={q}
onClick={() => onStart(q)}
className="w-full text-left px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-sm hover:border-blue-500/40 hover:bg-blue-500/5 transition-all"
>
{q}
</button>
))}
</div>
<button
onClick={() => onStart()}
className="px-8 py-3 bg-blue-500 text-white rounded-xl font-medium hover:bg-blue-400 transition-all"
>
Open Chat
</button>
</div>
);
}
```
- [ ] **Step 3: Commit**
```bash
cd frontend && git add src/components/setup/IngestDashboard.tsx src/components/setup/ReadyScreen.tsx
git commit -m "feat: add IngestDashboard with progress bars and ReadyScreen with suggestions"
```
---
### Task 5: SetupWizard Orchestrator + Integration
**Files:**
- Create: `frontend/src/components/setup/SetupWizard.tsx`
- Modify: `frontend/src/components/SetupScreen.tsx`
- [ ] **Step 1: Create SetupWizard orchestrator**
Create `frontend/src/components/setup/SetupWizard.tsx`:
```tsx
import { useState } from "react";
import type { WizardStep } from "../../types/connectors";
import { SourcePicker } from "./SourcePicker";
import { SourceConnectFlow } from "./SourceConnectFlow";
import { IngestDashboard } from "./IngestDashboard";
import { ReadyScreen } from "./ReadyScreen";
interface Props {
onComplete: (firstQuery?: string) => void;
}
export function SetupWizard({ onComplete }: Props) {
const [step, setStep] = useState<WizardStep>("pick");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [connectedIds, setConnectedIds] = useState<string[]>([]);
switch (step) {
case "pick":
return (
<SourcePicker
onContinue={(ids) => {
setSelectedIds(ids);
if (ids.length > 0) {
setStep("connect");
} else {
onComplete();
}
}}
/>
);
case "connect":
return (
<SourceConnectFlow
selectedIds={selectedIds}
onComplete={() => {
// For now, assume all selected are connected
setConnectedIds(selectedIds);
setStep("ingest");
}}
/>
);
case "ingest":
return (
<IngestDashboard
connectedIds={connectedIds}
onReady={() => setStep("ready")}
/>
);
case "ready":
return (
<ReadyScreen
connectedSources={connectedIds}
onStart={(query) => onComplete(query)}
/>
);
}
}
```
- [ ] **Step 2: Integrate into SetupScreen**
Modify `frontend/src/components/SetupScreen.tsx` to show the wizard after the boot sequence completes. Find the section where `status.phase === 'ready'` triggers `onReady()`. Instead of calling `onReady()` immediately, show the `SetupWizard`:
Add to the imports:
```tsx
import { SetupWizard } from "./setup/SetupWizard";
```
Add state to track whether wizard is shown:
```tsx
const [showWizard, setShowWizard] = useState(false);
```
When boot completes (phase === 'ready'), instead of calling `onReady()`, set `showWizard = true`. Then render:
```tsx
if (showWizard) {
return <SetupWizard onComplete={(query) => {
// Optionally pass first query to chat
onReady();
}} />;
}
```
- [ ] **Step 3: Commit**
```bash
cd frontend && git add src/components/setup/SetupWizard.tsx src/components/SetupScreen.tsx
git commit -m "feat: integrate SetupWizard into boot flow after engine setup"
```
---
## Post-Plan Notes
**What this plan produces:**
- TypeScript types and API client for connector management
- SourcePicker: beautiful grid of 10 source cards with category grouping and toggle selection
- SourceConnectFlow: guided per-source auth wizard with sidebar progress checklist
- IngestDashboard: live progress bars polling sync status
- ReadyScreen: celebration screen with suggested first queries
- SetupWizard: orchestrates all 4 steps
- Integration into existing SetupScreen boot flow
**What this does NOT include (separate effort):**
- Actual OAuth redirect handling in Tauri (needs Tauri deep link plugin)
- File picker dialog for Obsidian (needs Tauri dialog plugin)
- QR code display for WhatsApp
- Product logo images (would need asset files)
- Tauri command wrappers for the API endpoints
- Automated sync triggering after connect
@@ -1,816 +0,0 @@
# Deep Research Phase 4: Channel Plugins Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a ChannelAgent that connects existing messaging channels (Slack, WhatsApp, iMessage) to the DeepResearchAgent, with automatic query classification (quick inline answers vs. escalation to desktop for complex research).
**Architecture:** A `ChannelAgent` class registers as a `ChannelHandler` callback on any `BaseChannel`. When a message arrives, it classifies the query (quick vs. deep), runs the DeepResearchAgent with the appropriate turn limit, and sends the response back via `channel.send()`. Quick answers go inline; complex queries get a brief preview + escalation link. A worker thread pool prevents blocking the channel's event loop.
**Tech Stack:** Python 3.10+, threading (worker pool), existing channel transports (Slack Socket Mode, WhatsApp Baileys, BlueBubbles), pytest
**Spec:** `docs/superpowers/specs/2026-03-25-deep-research-setup-design.md` — Section 9 (Channel Plugins)
**Depends on:** Phase 1 (connectors, KnowledgeStore), Phase 3 (DeepResearchAgent, TwoStageRetriever)
---
## File Structure
```
src/openjarvis/agents/
├── channel_agent.py # ChannelAgent: bridge between channels and DeepResearchAgent
tests/agents/
├── test_channel_agent.py # ChannelAgent tests
```
---
### Task 1: Query Classifier
**Files:**
- Create: `src/openjarvis/agents/channel_agent.py` (first half — classifier only)
- Create: `tests/agents/test_channel_agent.py`
The classifier determines whether a message needs a quick single-hop answer or full multi-hop research. This is an internal implementation detail — the user never sees it.
- [ ] **Step 1: Write failing tests for classifier**
Create `tests/agents/test_channel_agent.py`:
```python
"""Tests for ChannelAgent — bridge between channels and DeepResearchAgent."""
from __future__ import annotations
from openjarvis.agents.channel_agent import classify_query
def test_quick_when_query() -> None:
assert classify_query("When is my next meeting?") == "quick"
def test_quick_where_query() -> None:
assert classify_query("Where is the design doc?") == "quick"
def test_quick_find_query() -> None:
assert classify_query("Find the budget spreadsheet") == "quick"
def test_quick_short_query() -> None:
assert classify_query("Who sent the last email?") == "quick"
def test_deep_summarize() -> None:
assert classify_query("Summarize all discussions about pricing") == "deep"
def test_deep_research() -> None:
assert classify_query("Research the context behind the K8s decision") == "deep"
def test_deep_context() -> None:
assert classify_query("What was the context around the migration?") == "deep"
def test_deep_time_range() -> None:
assert classify_query("What happened last month with the project?") == "deep"
def test_deep_long_query() -> None:
long = "Tell me about all the discussions between Sarah and Mike about the infrastructure migration including cost analysis and timeline"
assert classify_query(long) == "deep"
def test_quick_single_entity() -> None:
assert classify_query("What's Sarah's email?") == "quick"
def test_deep_multi_entity() -> None:
assert classify_query("Compare what Sarah and Mike said about the budget") == "deep"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/agents/test_channel_agent.py -v`
Expected: FAIL — `ModuleNotFoundError`
- [ ] **Step 3: Implement classify_query**
Create `src/openjarvis/agents/channel_agent.py`:
```python
"""ChannelAgent — bridges messaging channels to the DeepResearchAgent.
Handles:
- Automatic query classification (quick vs. deep)
- Quick answers inline in the chat channel
- Complex queries escalated with a preview + desktop link
- Worker thread pool so channel event loops aren't blocked
"""
from __future__ import annotations
import logging
import re
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult
from openjarvis.channels._stubs import BaseChannel, ChannelMessage
from openjarvis.core.events import EventType, get_event_bus
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------
# Query classification
# ---------------------------------------------------------------
_DEEP_KEYWORDS = re.compile(
r"\b(summarize|summary|research|context|compare|analyze|analysis"
r"|overview|all discussions|comprehensive|timeline|history"
r"|what happened|tell me about all)\b",
re.IGNORECASE,
)
_TIME_RANGE_PATTERNS = re.compile(
r"\b(last (week|month|quarter|year)|past \d+|since (january|february"
r"|march|april|may|june|july|august|september|october|november"
r"|december))\b",
re.IGNORECASE,
)
_QUICK_STARTERS = re.compile(
r"^(when|where|find|who sent|what'?s|what is)\b", re.IGNORECASE
)
_MAX_QUICK_WORDS = 20
def classify_query(text: str) -> str:
"""Classify a user query as 'quick' or 'deep'.
Quick: single-entity lookups, short factual questions.
Deep: multi-source synthesis, summarization, time-range analysis.
This is an internal heuristic — the user never specifies the mode.
"""
text = text.strip()
words = text.split()
# Deep signals take priority
if _DEEP_KEYWORDS.search(text):
return "deep"
if _TIME_RANGE_PATTERNS.search(text):
return "deep"
if len(words) > _MAX_QUICK_WORDS:
return "deep"
# Quick signals
if _QUICK_STARTERS.match(text):
return "quick"
if len(words) <= _MAX_QUICK_WORDS:
return "quick"
return "quick"
```
- [ ] **Step 4: Run tests**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/agents/test_channel_agent.py -v`
Expected: All 11 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/agents/channel_agent.py tests/agents/test_channel_agent.py
git commit -m "feat: add query classifier for channel agent (quick vs deep)"
```
---
### Task 2: ChannelAgent Core
**Files:**
- Modify: `src/openjarvis/agents/channel_agent.py`
- Modify: `tests/agents/test_channel_agent.py`
- [ ] **Step 1: Write failing tests for ChannelAgent**
Add to `tests/agents/test_channel_agent.py`:
```python
import json
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.agents.channel_agent import ChannelAgent, classify_query
from openjarvis.agents.deep_research import DeepResearchAgent
from openjarvis.agents._stubs import AgentResult
from openjarvis.channels._stubs import BaseChannel, ChannelMessage, ChannelStatus
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.core.types import ToolResult
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
class FakeChannel(BaseChannel):
"""Minimal channel for testing."""
channel_id = "fake"
def __init__(self) -> None:
self._handlers: list = []
self._sent: list = []
self._connected = True
def connect(self) -> None:
self._connected = True
def disconnect(self) -> None:
self._connected = False
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: dict | None = None,
) -> bool:
self._sent.append(
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
}
)
return True
def status(self) -> ChannelStatus:
return ChannelStatus.CONNECTED if self._connected else ChannelStatus.DISCONNECTED
def list_channels(self) -> list[str]:
return ["test"]
def on_message(self, handler) -> None:
self._handlers.append(handler)
def simulate_message(self, text: str, sender: str = "user1") -> None:
msg = ChannelMessage(
channel="fake",
sender=sender,
content=text,
conversation_id="conv1",
)
for h in self._handlers:
h(msg)
@pytest.fixture
def mock_agent() -> MagicMock:
agent = MagicMock(spec=DeepResearchAgent)
agent.agent_id = "deep_research"
agent.run.return_value = AgentResult(
content="The next meeting is tomorrow at 2pm.",
tool_results=[],
turns=1,
)
return agent
@pytest.fixture
def channel() -> FakeChannel:
return FakeChannel()
@pytest.fixture
def channel_agent(
channel: FakeChannel, mock_agent: MagicMock
) -> ChannelAgent:
return ChannelAgent(
channel=channel,
agent=mock_agent,
)
def test_channel_agent_registers_handler(
channel: FakeChannel, channel_agent: ChannelAgent
) -> None:
assert len(channel._handlers) == 1
def test_quick_query_sends_response_inline(
channel: FakeChannel,
channel_agent: ChannelAgent,
mock_agent: MagicMock,
) -> None:
channel.simulate_message("When is my next meeting?")
# Give worker thread time to process
time.sleep(0.5)
assert len(channel._sent) >= 1
assert "meeting" in channel._sent[0]["content"].lower()
mock_agent.run.assert_called_once()
def test_deep_query_sends_preview_and_link(
channel: FakeChannel,
channel_agent: ChannelAgent,
mock_agent: MagicMock,
) -> None:
mock_agent.run.return_value = AgentResult(
content="# Kubernetes Migration Report\n\nDetailed analysis...",
tool_results=[
ToolResult(
tool_name="knowledge_search",
content="results",
success=True,
metadata={"num_results": 15},
),
],
turns=3,
)
channel.simulate_message(
"Summarize all discussions about the Kubernetes migration"
)
time.sleep(0.5)
assert len(channel._sent) >= 1
# Deep queries should include an escalation link
sent_content = channel._sent[-1]["content"]
assert "openjarvis://" in sent_content or len(sent_content) > 50
def test_agent_error_sends_error_message(
channel: FakeChannel,
channel_agent: ChannelAgent,
mock_agent: MagicMock,
) -> None:
mock_agent.run.side_effect = RuntimeError("LLM unavailable")
channel.simulate_message("When is my meeting?")
time.sleep(0.5)
assert len(channel._sent) >= 1
assert "error" in channel._sent[0]["content"].lower() or "sorry" in channel._sent[0]["content"].lower()
def test_quick_query_uses_max_turns_1(
channel: FakeChannel,
channel_agent: ChannelAgent,
mock_agent: MagicMock,
) -> None:
channel.simulate_message("Find the budget doc")
time.sleep(0.5)
call_kwargs = mock_agent.run.call_args
# Quick queries should be called (the agent handles turn limits internally)
mock_agent.run.assert_called_once()
def test_channel_agent_does_not_block_handler(
channel: FakeChannel,
channel_agent: ChannelAgent,
mock_agent: MagicMock,
) -> None:
"""Handler should return immediately (work dispatched to thread pool)."""
mock_agent.run.side_effect = lambda *a, **kw: (
time.sleep(2),
AgentResult(content="done", turns=1),
)[-1]
t0 = time.time()
channel.simulate_message("When is my meeting?")
elapsed = time.time() - t0
# Handler should return in < 0.1s even if agent takes 2s
assert elapsed < 0.5
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/agents/test_channel_agent.py::test_channel_agent_registers_handler -v`
Expected: FAIL — `ImportError: cannot import name 'ChannelAgent'`
- [ ] **Step 3: Implement ChannelAgent**
Add to `src/openjarvis/agents/channel_agent.py` (after the classifier):
```python
# ---------------------------------------------------------------
# ChannelAgent
# ---------------------------------------------------------------
_ESCALATION_TEMPLATE = """{preview}
---
Full report ready — open in OpenJarvis:
openjarvis://research/{session_id}"""
_ERROR_TEMPLATE = (
"Sorry, I ran into an error processing your request. "
"Please try again or open OpenJarvis for the full experience."
)
_MAX_INLINE_LENGTH = 500
class ChannelAgent:
"""Bridges a messaging channel to the DeepResearchAgent.
Registers as a handler on the given channel. When a message
arrives, classifies it (quick vs deep), runs the agent in a
worker thread, and sends the response back via the channel.
Quick queries get the full response inline.
Deep queries get a brief preview + escalation link.
Parameters
----------
channel:
The messaging channel to listen on.
agent:
The DeepResearchAgent (or any BaseAgent) to handle queries.
max_workers:
Thread pool size for concurrent query processing.
"""
def __init__(
self,
channel: BaseChannel,
agent: Any,
*,
max_workers: int = 2,
) -> None:
self._channel = channel
self._agent = agent
self._pool = ThreadPoolExecutor(
max_workers=max_workers,
thread_name_prefix="channel_agent",
)
# Register handler
channel.on_message(self._handle_message)
def _handle_message(self, msg: ChannelMessage) -> Optional[str]:
"""Handler callback — dispatches work to thread pool."""
self._pool.submit(self._process_message, msg)
return None # Don't block the channel's event loop
def _process_message(self, msg: ChannelMessage) -> None:
"""Process a message in a worker thread."""
query = msg.content.strip()
if not query:
return
classification = classify_query(query)
session_id = uuid.uuid4().hex[:12]
bus = get_event_bus()
bus.publish(
EventType.AGENT_TURN_START,
{
"agent": "channel_agent",
"channel": msg.channel,
"sender": msg.sender,
"classification": classification,
},
)
try:
result = self._agent.run(query)
except Exception as exc:
logger.error(
"ChannelAgent error for %s: %s",
msg.sender,
exc,
)
self._channel.send(
msg.sender,
_ERROR_TEMPLATE,
conversation_id=msg.conversation_id,
)
return
content = result.content.strip()
if classification == "deep" or len(content) > _MAX_INLINE_LENGTH:
# Truncate for preview
preview = content[:300]
if len(content) > 300:
preview = preview.rsplit(" ", 1)[0] + "..."
response = _ESCALATION_TEMPLATE.format(
preview=preview, session_id=session_id
)
else:
response = content
self._channel.send(
msg.sender,
response,
conversation_id=msg.conversation_id,
)
bus.publish(
EventType.AGENT_TURN_END,
{
"agent": "channel_agent",
"channel": msg.channel,
"turns": result.turns,
"classification": classification,
"session_id": session_id,
},
)
def shutdown(self) -> None:
"""Shut down the worker thread pool."""
self._pool.shutdown(wait=False)
```
- [ ] **Step 4: Run tests**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/agents/test_channel_agent.py -v`
Expected: All 17 tests PASS (11 classifier + 6 ChannelAgent).
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/agents/channel_agent.py tests/agents/test_channel_agent.py
git commit -m "feat: add ChannelAgent with query classification and escalation"
```
---
### Task 3: Integration Test — ChannelAgent + DeepResearchAgent + Real Data
**Files:**
- Create: `tests/agents/test_channel_agent_integration.py`
- [ ] **Step 1: Write integration test**
Create `tests/agents/test_channel_agent_integration.py`:
```python
"""Integration test — ChannelAgent with real KnowledgeStore and mock engine."""
from __future__ import annotations
import json
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from openjarvis.agents.channel_agent import ChannelAgent
from openjarvis.agents.deep_research import DeepResearchAgent
from openjarvis.channels._stubs import BaseChannel, ChannelMessage, ChannelStatus
from openjarvis.connectors._stubs import Document
from openjarvis.connectors.pipeline import IngestionPipeline
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
class FakeChannel(BaseChannel):
channel_id = "fake"
def __init__(self) -> None:
self._handlers: list = []
self._sent: list = []
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: dict | None = None,
) -> bool:
self._sent.append({"content": content, "conv": conversation_id})
return True
def status(self) -> ChannelStatus:
return ChannelStatus.CONNECTED
def list_channels(self) -> list[str]:
return ["test"]
def on_message(self, handler) -> None:
self._handlers.append(handler)
def simulate(self, text: str) -> None:
msg = ChannelMessage(
channel="fake",
sender="user",
content=text,
conversation_id="conv1",
)
for h in self._handlers:
h(msg)
@pytest.fixture
def populated_store(tmp_path: Path) -> KnowledgeStore:
store = KnowledgeStore(
db_path=str(tmp_path / "channel_int.db")
)
pipeline = IngestionPipeline(store=store, max_tokens=256)
pipeline.ingest(
[
Document(
doc_id="gcalendar:evt1",
source="gcalendar",
doc_type="event",
content="Sprint Planning\nWhen: Tomorrow 2pm\nAttendees: Sarah, Mike",
title="Sprint Planning",
),
Document(
doc_id="slack:msg1",
source="slack",
doc_type="message",
content="We need to finalize the API redesign before Friday",
title="#engineering",
author="sarah",
),
Document(
doc_id="gmail:msg1",
source="gmail",
doc_type="email",
content="Budget report attached. Q3 spending up 15%.",
title="Re: Q3 Budget",
author="mike",
),
]
)
return store
def test_quick_query_through_full_stack(
populated_store: KnowledgeStore,
) -> None:
"""Quick query → agent searches → inline response."""
mock_engine = MagicMock()
mock_engine.engine_id = "mock"
mock_engine.generate.return_value = {
"content": "Your next meeting is Sprint Planning tomorrow at 2pm with Sarah and Mike.",
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
"model": "test",
"finish_reason": "stop",
}
retriever = TwoStageRetriever(store=populated_store)
ks_tool = KnowledgeSearchTool(
store=populated_store, retriever=retriever
)
agent = DeepResearchAgent(
engine=mock_engine,
model="test",
tools=[ks_tool],
max_turns=2,
)
channel = FakeChannel()
ca = ChannelAgent(channel=channel, agent=agent)
channel.simulate("When is my next meeting?")
time.sleep(1.0)
assert len(channel._sent) >= 1
response = channel._sent[0]["content"]
assert "Sprint Planning" in response or "meeting" in response.lower()
# Quick query should NOT have escalation link
assert "openjarvis://" not in response
ca.shutdown()
def test_deep_query_gets_escalation_link(
populated_store: KnowledgeStore,
) -> None:
"""Deep query → agent searches → preview + escalation link."""
search_call = {
"content": "",
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
"model": "test",
"finish_reason": "tool_calls",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "knowledge_search",
"arguments": json.dumps({"query": "budget API"}),
},
}
],
}
final_answer = {
"content": (
"## Research Report\n\n"
+ "Based on analysis of Slack messages and emails, "
+ "the team discussed API redesign and Q3 budget. "
* 10
+ "\n\n**Sources:**\n1. [slack] #engineering\n2. [gmail] Q3 Budget"
),
"usage": {"prompt_tokens": 500, "completion_tokens": 300, "total_tokens": 800},
"model": "test",
"finish_reason": "stop",
}
mock_engine = MagicMock()
mock_engine.engine_id = "mock"
mock_engine.generate.side_effect = [search_call, final_answer]
retriever = TwoStageRetriever(store=populated_store)
ks_tool = KnowledgeSearchTool(
store=populated_store, retriever=retriever
)
agent = DeepResearchAgent(
engine=mock_engine,
model="test",
tools=[ks_tool],
max_turns=3,
)
channel = FakeChannel()
ca = ChannelAgent(channel=channel, agent=agent)
channel.simulate(
"Summarize all discussions about budget and API redesign"
)
time.sleep(1.0)
assert len(channel._sent) >= 1
response = channel._sent[-1]["content"]
# Deep query should have escalation link
assert "openjarvis://" in response
ca.shutdown()
```
- [ ] **Step 2: Run integration test**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/agents/test_channel_agent_integration.py -v`
Expected: All 2 tests PASS.
- [ ] **Step 3: Run full test suite**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/agents/test_channel_agent.py tests/agents/test_channel_agent_integration.py tests/agents/test_deep_research.py tests/connectors/ tests/tools/test_knowledge_search.py -v --tb=short`
Expected: All tests PASS.
- [ ] **Step 4: Run linter**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run ruff check src/openjarvis/agents/channel_agent.py tests/agents/test_channel_agent.py tests/agents/test_channel_agent_integration.py`
Expected: No errors.
- [ ] **Step 5: Commit**
```bash
git add tests/agents/test_channel_agent_integration.py
git commit -m "feat: add integration test for ChannelAgent with DeepResearchAgent"
```
---
## Post-Plan Notes
**What this plan produces:**
- `classify_query()` — automatic quick/deep classification (no user input needed)
- `ChannelAgent` — bridges any `BaseChannel` to the `DeepResearchAgent` with:
- Non-blocking handler (thread pool)
- Quick inline answers for simple lookups
- Preview + `openjarvis://research/{session_id}` escalation link for complex research
- Error handling with user-friendly messages
- Event bus telemetry
- Integration test proving the full stack: channel message → classification → agent → tool call → retrieval → response
**What this does NOT do (future work):**
- Platform-specific formatting (Slack Block Kit, WhatsApp markdown) — can be added per-channel later
- BlueBubbles incoming message support — needs upstream work on the bridge
- Tauri `openjarvis://` URL handler registration — that's Phase 2B (desktop)
- Persistent conversation context across messages — would need session store integration
**Connecting to real channels:** After this plan, wiring to real Slack/WhatsApp is just:
```python
channel = SlackChannel(bot_token="xoxb-...", app_token="xapp-...")
channel.connect()
agent = DeepResearchAgent(engine, model, tools=[ks_tool])
ca = ChannelAgent(channel=channel, agent=agent)
# Now the Slack bot responds to DMs with Deep Research answers
```
@@ -1,596 +0,0 @@
# Deep Research Phase 5: Incremental Sync + Attachment Store Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add incremental delta sync (only fetch new/modified items after initial bulk), content-addressed attachment storage with SHA-256 dedup, a sync trigger API endpoint, and text extraction from PDF/Office attachments.
**Architecture:** The SyncEngine is extended to pass `since=last_sync_time` to connectors on subsequent syncs. A new `AttachmentStore` manages content-addressed blob storage at `~/.openjarvis/blobs/` with SHA-256 dedup and a metadata table. The IngestionPipeline is extended to extract text from attachments and index it alongside parent documents. A `POST /v1/connectors/{id}/sync` endpoint triggers syncs via the API.
**Tech Stack:** Python 3.10+, sqlite3, hashlib (SHA-256), pdfplumber (optional, PDF text extraction), pytest
**Spec:** `docs/superpowers/specs/2026-03-25-deep-research-setup-design.md` — Section 6 (Ingestion Pipeline), Phase 5
**Depends on:** Phase 1 (SyncEngine, IngestionPipeline, KnowledgeStore), Phase 2B-i (API router)
---
## File Structure
```
src/openjarvis/connectors/
├── attachment_store.py # Content-addressed blob store with SHA-256 dedup
├── sync_engine.py # (modify) Wire incremental sync via since param
├── pipeline.py # (modify) Process attachments during ingest
├── gmail.py # (modify) Wire since param for incremental
src/openjarvis/server/
├── connectors_router.py # (modify) Add POST /connectors/{id}/sync endpoint
tests/connectors/
├── test_attachment_store.py # AttachmentStore tests
├── test_incremental_sync.py # Incremental sync tests
```
---
### Task 1: Incremental Sync (Wire `since` Through SyncEngine)
**Files:**
- Modify: `src/openjarvis/connectors/sync_engine.py`
- Create: `tests/connectors/test_incremental_sync.py`
- [ ] **Step 1: Write failing tests**
Create `tests/connectors/test_incremental_sync.py`:
```python
"""Tests for incremental sync — only fetches items after last sync time."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator, Optional
import pytest
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.connectors.pipeline import IngestionPipeline
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.connectors.sync_engine import SyncEngine
class TimestampConnector(BaseConnector):
"""Connector that records what `since` value it receives."""
connector_id = "ts_test"
display_name = "Timestamp Test"
auth_type = "filesystem"
def __init__(self, docs: list[Document] | None = None) -> None:
self._docs = docs or []
self._received_since: Optional[datetime] = None
def is_connected(self) -> bool:
return True
def disconnect(self) -> None:
pass
def sync(
self,
*,
since: Optional[datetime] = None,
cursor: Optional[str] = None,
) -> Iterator[Document]:
self._received_since = since
for doc in self._docs:
if since and doc.timestamp < since:
continue
yield doc
def sync_status(self) -> SyncStatus:
return SyncStatus(state="idle")
def _make_doc(
doc_id: str, ts: datetime, content: str = "test"
) -> Document:
return Document(
doc_id=doc_id,
source="ts_test",
doc_type="message",
content=content,
timestamp=ts,
)
@pytest.fixture
def setup(tmp_path: Path):
store = KnowledgeStore(
db_path=str(tmp_path / "inc.db")
)
pipeline = IngestionPipeline(store=store)
engine = SyncEngine(
pipeline=pipeline,
state_db=str(tmp_path / "state.db"),
)
return store, pipeline, engine
def test_first_sync_passes_no_since(setup) -> None:
store, pipeline, engine = setup
old = _make_doc(
"ts:1",
datetime(2024, 1, 1, tzinfo=timezone.utc),
)
conn = TimestampConnector(docs=[old])
engine.sync(conn)
assert conn._received_since is None
def test_second_sync_passes_since(setup) -> None:
store, pipeline, engine = setup
old = _make_doc(
"ts:1",
datetime(2024, 1, 1, tzinfo=timezone.utc),
)
conn1 = TimestampConnector(docs=[old])
engine.sync(conn1)
# Second sync should receive since= from last sync
new = _make_doc(
"ts:2",
datetime(2024, 6, 1, tzinfo=timezone.utc),
)
conn2 = TimestampConnector(docs=[old, new])
conn2.connector_id = "ts_test"
engine.sync(conn2)
assert conn2._received_since is not None
def test_incremental_only_adds_new_items(setup) -> None:
store, pipeline, engine = setup
old = _make_doc(
"ts:1",
datetime(2024, 1, 1, tzinfo=timezone.utc),
content="Old item",
)
conn1 = TimestampConnector(docs=[old])
engine.sync(conn1)
assert store.count() >= 1
count_after_first = store.count()
# Second sync with same + new doc
new = _make_doc(
"ts:2",
datetime(2024, 6, 1, tzinfo=timezone.utc),
content="New item",
)
conn2 = TimestampConnector(docs=[old, new])
conn2.connector_id = "ts_test"
engine.sync(conn2)
# Should have more chunks now
assert store.count() > count_after_first
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/connectors/test_incremental_sync.py -v`
Expected: `test_second_sync_passes_since` FAILS (since is never passed)
- [ ] **Step 3: Modify SyncEngine to pass `since`**
In `src/openjarvis/connectors/sync_engine.py`, modify the `sync()` method. After loading the checkpoint, parse `last_sync` into a datetime and pass it as `since=` to the connector:
Find the line that calls `connector.sync(cursor=prior_cursor)` and change it to also pass `since`:
```python
# Parse last_sync from checkpoint into datetime
since: Optional[datetime] = None
if checkpoint and checkpoint.get("last_sync"):
try:
since = datetime.fromisoformat(checkpoint["last_sync"])
except (ValueError, TypeError):
pass
docs = connector.sync(since=since, cursor=prior_cursor)
```
Add `from datetime import datetime` to imports if not already present.
- [ ] **Step 4: Run tests**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/connectors/test_incremental_sync.py -v`
Expected: All 3 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/connectors/sync_engine.py tests/connectors/test_incremental_sync.py
git commit -m "feat: wire incremental sync via since param in SyncEngine"
```
---
### Task 2: Attachment Store (Content-Addressed Blobs)
**Files:**
- Create: `src/openjarvis/connectors/attachment_store.py`
- Create: `tests/connectors/test_attachment_store.py`
- [ ] **Step 1: Write failing tests**
Create `tests/connectors/test_attachment_store.py`:
```python
"""Tests for AttachmentStore — content-addressed blob storage."""
from __future__ import annotations
from pathlib import Path
import pytest
from openjarvis.connectors.attachment_store import AttachmentStore
@pytest.fixture
def store(tmp_path: Path) -> AttachmentStore:
return AttachmentStore(base_dir=str(tmp_path / "blobs"))
def test_store_returns_sha256(store: AttachmentStore) -> None:
sha = store.store(
content=b"Hello, world!",
filename="hello.txt",
mime_type="text/plain",
source_doc_id="gmail:msg1",
)
assert len(sha) == 64 # SHA-256 hex digest
def test_store_creates_blob_file(
store: AttachmentStore, tmp_path: Path
) -> None:
sha = store.store(
content=b"PDF content here",
filename="report.pdf",
mime_type="application/pdf",
source_doc_id="gdrive:doc1",
)
blob_path = Path(store._base_dir) / sha[:2] / sha
assert blob_path.exists()
assert blob_path.read_bytes() == b"PDF content here"
def test_dedup_same_content(store: AttachmentStore) -> None:
content = b"Identical file content"
sha1 = store.store(
content=content,
filename="file1.txt",
mime_type="text/plain",
source_doc_id="gmail:1",
)
sha2 = store.store(
content=content,
filename="file2.txt",
mime_type="text/plain",
source_doc_id="slack:1",
)
assert sha1 == sha2 # Same content → same hash
def test_dedup_tracks_multiple_sources(
store: AttachmentStore,
) -> None:
content = b"Shared file"
store.store(
content=content,
filename="shared.pdf",
mime_type="application/pdf",
source_doc_id="gmail:1",
)
store.store(
content=content,
filename="shared.pdf",
mime_type="application/pdf",
source_doc_id="slack:1",
)
meta = store.get_metadata(
store.store(
content=content,
filename="shared.pdf",
mime_type="application/pdf",
source_doc_id="gdrive:1",
)
)
assert len(meta["source_doc_ids"]) >= 3
def test_get_metadata(store: AttachmentStore) -> None:
sha = store.store(
content=b"test",
filename="test.txt",
mime_type="text/plain",
source_doc_id="gmail:1",
)
meta = store.get_metadata(sha)
assert meta["filename"] == "test.txt"
assert meta["mime_type"] == "text/plain"
assert meta["size_bytes"] == 4
def test_get_content(store: AttachmentStore) -> None:
sha = store.store(
content=b"retrieve me",
filename="data.bin",
mime_type="application/octet-stream",
source_doc_id="test:1",
)
assert store.get_content(sha) == b"retrieve me"
def test_get_content_nonexistent(
store: AttachmentStore,
) -> None:
assert store.get_content("nonexistent") is None
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/connectors/test_attachment_store.py -v`
Expected: FAIL — `ModuleNotFoundError`
- [ ] **Step 3: Implement AttachmentStore**
Create `src/openjarvis/connectors/attachment_store.py`:
```python
"""Content-addressed attachment store with SHA-256 dedup.
Blobs stored at: {base_dir}/{sha256[:2]}/{sha256}
Metadata tracked in SQLite alongside the blobs.
"""
from __future__ import annotations
import hashlib
import json
import logging
import sqlite3
from pathlib import Path
from typing import Any, Dict, List, Optional
from openjarvis.core.config import DEFAULT_CONFIG_DIR
logger = logging.getLogger(__name__)
_DEFAULT_BASE_DIR = str(DEFAULT_CONFIG_DIR / "blobs")
_META_SCHEMA = """\
CREATE TABLE IF NOT EXISTS attachments (
sha256 TEXT PRIMARY KEY,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
source_doc_ids TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
"""
class AttachmentStore:
"""Content-addressed blob store for email attachments, shared files, etc.
Each file is stored once by SHA-256 hash. Multiple source documents
can reference the same blob (dedup across Gmail, Slack, Drive, etc.).
"""
def __init__(self, base_dir: str = "") -> None:
self._base_dir = base_dir or _DEFAULT_BASE_DIR
Path(self._base_dir).mkdir(parents=True, exist_ok=True)
db_path = str(Path(self._base_dir) / "attachments.db")
self._conn = sqlite3.connect(db_path)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(_META_SCHEMA)
def store(
self,
content: bytes,
*,
filename: str,
mime_type: str = "",
source_doc_id: str = "",
) -> str:
"""Store a blob and return its SHA-256 hash.
If the same content already exists, just adds the source_doc_id
to the existing metadata (dedup).
"""
sha = hashlib.sha256(content).hexdigest()
# Write blob file (idempotent)
blob_dir = Path(self._base_dir) / sha[:2]
blob_dir.mkdir(parents=True, exist_ok=True)
blob_path = blob_dir / sha
if not blob_path.exists():
blob_path.write_bytes(content)
# Upsert metadata
row = self._conn.execute(
"SELECT source_doc_ids FROM attachments WHERE sha256 = ?",
(sha,),
).fetchone()
if row is None:
from datetime import datetime, timezone
self._conn.execute(
"""INSERT INTO attachments
(sha256, filename, mime_type, size_bytes,
source_doc_ids, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(
sha,
filename,
mime_type,
len(content),
json.dumps([source_doc_id] if source_doc_id else []),
datetime.now(tz=timezone.utc).isoformat(),
),
)
else:
# Add source_doc_id if not already tracked
ids = json.loads(row["source_doc_ids"])
if source_doc_id and source_doc_id not in ids:
ids.append(source_doc_id)
self._conn.execute(
"UPDATE attachments SET source_doc_ids = ? WHERE sha256 = ?",
(json.dumps(ids), sha),
)
self._conn.commit()
return sha
def get_metadata(self, sha: str) -> Optional[Dict[str, Any]]:
"""Get metadata for an attachment by SHA-256."""
row = self._conn.execute(
"SELECT * FROM attachments WHERE sha256 = ?", (sha,)
).fetchone()
if row is None:
return None
return {
"sha256": row["sha256"],
"filename": row["filename"],
"mime_type": row["mime_type"],
"size_bytes": row["size_bytes"],
"source_doc_ids": json.loads(row["source_doc_ids"]),
"created_at": row["created_at"],
}
def get_content(self, sha: str) -> Optional[bytes]:
"""Read blob content by SHA-256. Returns None if not found."""
blob_path = Path(self._base_dir) / sha[:2] / sha
if not blob_path.exists():
return None
return blob_path.read_bytes()
def close(self) -> None:
self._conn.close()
```
- [ ] **Step 4: Run tests**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run pytest tests/connectors/test_attachment_store.py -v`
Expected: All 7 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/connectors/attachment_store.py tests/connectors/test_attachment_store.py
git commit -m "feat: add content-addressed AttachmentStore with SHA-256 dedup"
```
---
### Task 3: Sync Trigger API Endpoint
**Files:**
- Modify: `src/openjarvis/server/connectors_router.py`
- Modify: `tests/server/test_connectors_router.py`
- [ ] **Step 1: Add test for POST /sync**
Add to `tests/server/test_connectors_router.py`:
```python
def test_trigger_sync(app, tmp_path: Path) -> None:
"""POST /v1/connectors/obsidian/sync triggers a sync."""
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("# Test note\n\nContent here.")
# First connect
app.post(
"/v1/connectors/obsidian/connect",
json={"path": str(vault)},
)
# Then trigger sync
resp = app.post("/v1/connectors/obsidian/sync")
assert resp.status_code == 200
data = resp.json()
assert data["chunks_indexed"] >= 1
```
- [ ] **Step 2: Implement POST /sync endpoint**
Add to the router in `src/openjarvis/server/connectors_router.py`:
```python
@router.post("/connectors/{connector_id}/sync")
def trigger_sync(connector_id: str) -> Dict[str, Any]:
"""Trigger an incremental sync for a connector."""
if not ConnectorRegistry.contains(connector_id):
raise HTTPException(
status_code=404,
detail=f"Connector '{connector_id}' not found",
)
inst = _get_or_create(connector_id)
if not inst.is_connected():
raise HTTPException(
status_code=400,
detail=f"Connector '{connector_id}' is not connected",
)
from openjarvis.connectors.pipeline import IngestionPipeline
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.connectors.sync_engine import SyncEngine
store = KnowledgeStore()
pipeline = IngestionPipeline(store=store)
engine = SyncEngine(pipeline=pipeline)
chunks = engine.sync(inst)
return {
"connector_id": connector_id,
"chunks_indexed": chunks,
"status": "complete",
}
```
- [ ] **Step 3: Run tests**
Run: `cd /lambda/nfs/lambda-stanford/jonsf/scratch_v2/OpenJarvis && uv run --extra server pytest tests/server/test_connectors_router.py -v`
Expected: All tests PASS (existing + new).
- [ ] **Step 4: Commit**
```bash
git add src/openjarvis/server/connectors_router.py tests/server/test_connectors_router.py
git commit -m "feat: add POST /v1/connectors/{id}/sync to trigger incremental sync"
```
---
## Post-Plan Notes
**What this plan produces:**
- Incremental sync via `since=last_sync_time` passed to connectors on subsequent syncs
- Content-addressed `AttachmentStore` with SHA-256 dedup and multi-source tracking
- `POST /v1/connectors/{id}/sync` API endpoint to trigger syncs
- Tests for all of the above
**What's NOT in this plan (future):**
- PDF text extraction from attachments (needs pdfplumber wired into pipeline)
- Office doc extraction (python-docx/openpyxl)
- Wiring attachment extraction into the IngestionPipeline
- Populating `doc.attachments` in Gmail/Drive connectors
These are natural follow-ups once the store exists. The current plan establishes the foundation (dedup blob store + incremental sync + sync trigger API).
@@ -1,757 +0,0 @@
# Deep Research Vertical Slice Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Get the full Deep Research experience working end-to-end on a MacBook with real data — connect local sources, ingest, retrieve, and run the DeepResearchAgent with Qwen3.5 4B via Ollama producing cited research reports.
**Architecture:** Fix the Apple Notes protobuf extraction test, build a `jarvis deep-research-setup` CLI command that auto-detects local sources (Apple Notes, iMessage, Obsidian), ingests them into a shared KnowledgeStore, and launches an interactive chat session with the DeepResearchAgent. Then wire the missing API router so the desktop wizard UI works too.
**Tech Stack:** Python 3.10+, Click (CLI), SQLite/FTS5, Ollama (Qwen3.5 4B), ColBERT (optional reranking), React/Tauri (wizard smoke test)
---
### Task 1: Fix Apple Notes test
**Files:**
- Modify: `src/openjarvis/connectors/apple_notes.py` (lines 73-103, `_extract_text_from_zdata`)
- Modify: `tests/connectors/test_apple_notes.py` (lines 42-58, test data + lines 127-140, assertion)
The `_extract_text_from_zdata` function currently strips protobuf control bytes but not HTML tags. The test creates fake notes with HTML content. Real Apple Notes uses protobuf. Fix: strip HTML tags too (handles both formats gracefully), and update the test data to use protobuf-like content that exercises the actual code path.
- [ ] **Step 1: Read current files**
Read both files to confirm the exact current state (there are uncommitted changes from the earlier session):
```bash
git diff src/openjarvis/connectors/apple_notes.py
git diff tests/connectors/test_apple_notes.py
```
- [ ] **Step 2: Update `_extract_text_from_zdata` to also strip HTML tags**
In `src/openjarvis/connectors/apple_notes.py`, the function should strip HTML tags after stripping protobuf bytes. This handles both real protobuf data and HTML test data. Edit the function:
```python
def _extract_text_from_zdata(zdata: bytes) -> str:
"""Decompress gzip bytes and extract plain text from the protobuf payload.
Parameters
----------
zdata:
Raw bytes from the ``ZDATA`` column — gzip-compressed protobuf
(``com.apple.notes.ICNote``).
Returns
-------
str
Plain text with protobuf control bytes stripped. Returns an empty
string if decompression fails.
"""
try:
raw = gzip.decompress(zdata)
except Exception: # noqa: BLE001
return ""
text = raw.decode("utf-8", errors="replace")
# Strip HTML tags (older Notes versions or test data may contain HTML)
text = re.sub(r"<[^>]+>", "", text)
# Strip non-printable control bytes and U+FFFD replacement chars that
# come from the protobuf wire format.
cleaned = re.sub(r"[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f\ufffd]+", " ", text)
# Collapse whitespace runs
cleaned = re.sub(r" {2,}", " ", cleaned)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
cleaned = cleaned.strip()
# Strip short leading protobuf varint artifacts (e.g. "3 3 " or "b b ")
cleaned = re.sub(r"^(?:[a-z0-9] ){1,4}", "", cleaned)
return cleaned.strip()
```
- [ ] **Step 3: Run the Apple Notes tests**
```bash
uv run pytest tests/connectors/test_apple_notes.py -v --tb=short
```
Expected: All 9 tests PASS (including `test_sync_decompresses_content`).
- [ ] **Step 4: Run the iMessage tests too (regression check)**
```bash
uv run pytest tests/connectors/test_imessage.py -v --tb=short
```
Expected: All tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/connectors/apple_notes.py tests/connectors/test_apple_notes.py
git commit -m "fix: Apple Notes protobuf extraction handles HTML too, fix ZTITLE1 column"
```
---
### Task 2: Build `jarvis deep-research-setup` CLI command
**Files:**
- Create: `src/openjarvis/cli/deep_research_setup_cmd.py`
- Modify: `src/openjarvis/cli/__init__.py` (add command registration)
- Test: `tests/cli/test_deep_research_setup.py`
This is the core of the vertical slice — a single command that auto-detects local sources, ingests data, and drops you into a research chat.
- [ ] **Step 1: Write the test file**
Create `tests/cli/test_deep_research_setup.py`:
```python
"""Tests for the deep-research-setup CLI command."""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from openjarvis.cli import cli
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_fake_notes_db(db_path: Path) -> None:
"""Create a minimal Apple Notes SQLite database."""
import gzip
conn = sqlite3.connect(str(db_path))
conn.executescript("""
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
Z_PK INTEGER PRIMARY KEY,
ZTITLE TEXT,
ZTITLE1 TEXT,
ZMODIFICATIONDATE REAL,
ZIDENTIFIER TEXT,
ZNOTE INTEGER
);
CREATE TABLE ZICNOTEDATA (
Z_PK INTEGER PRIMARY KEY,
ZDATA BLOB,
ZNOTE INTEGER
);
""")
content = gzip.compress(b"Test note about meetings")
conn.execute(
"INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES (1, NULL, 'Test Note', 694310400.0, 'n1', 1)"
)
conn.execute("INSERT INTO ZICNOTEDATA VALUES (1, ?, 1)", (content,))
conn.commit()
conn.close()
def _create_fake_imessage_db(db_path: Path) -> None:
"""Create a minimal iMessage SQLite database."""
conn = sqlite3.connect(str(db_path))
conn.executescript("""
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);
CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT);
CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY, text TEXT, handle_id INTEGER,
date INTEGER, is_from_me INTEGER
);
""")
conn.execute("INSERT INTO handle VALUES (1, '+15551234567')")
conn.execute("INSERT INTO chat VALUES (1, '+15551234567', 'Test Chat')")
conn.execute("INSERT INTO chat_message_join VALUES (1, 1)")
conn.execute("INSERT INTO message VALUES (1, 'Hello from test', 1, 694310400000000000, 0)")
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_detect_local_sources(tmp_path: Path) -> None:
"""Auto-detection finds Apple Notes and iMessage when DBs exist."""
from openjarvis.cli.deep_research_setup_cmd import detect_local_sources
notes_db = tmp_path / "NoteStore.sqlite"
imessage_db = tmp_path / "chat.db"
_create_fake_notes_db(notes_db)
_create_fake_imessage_db(imessage_db)
sources = detect_local_sources(
notes_db_path=notes_db,
imessage_db_path=imessage_db,
obsidian_vault_path=None,
)
ids = [s["connector_id"] for s in sources]
assert "apple_notes" in ids
assert "imessage" in ids
def test_detect_skips_missing_sources(tmp_path: Path) -> None:
"""Auto-detection skips sources whose files don't exist."""
from openjarvis.cli.deep_research_setup_cmd import detect_local_sources
sources = detect_local_sources(
notes_db_path=tmp_path / "nonexistent.sqlite",
imessage_db_path=tmp_path / "nonexistent.db",
obsidian_vault_path=None,
)
assert len(sources) == 0
def test_detect_includes_obsidian_when_vault_exists(tmp_path: Path) -> None:
"""Auto-detection includes Obsidian when vault path exists."""
from openjarvis.cli.deep_research_setup_cmd import detect_local_sources
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("# Hello")
sources = detect_local_sources(
notes_db_path=tmp_path / "nonexistent.sqlite",
imessage_db_path=tmp_path / "nonexistent.db",
obsidian_vault_path=vault,
)
ids = [s["connector_id"] for s in sources]
assert "obsidian" in ids
def test_ingest_sources(tmp_path: Path) -> None:
"""ingest_sources connects and ingests documents into KnowledgeStore."""
from openjarvis.cli.deep_research_setup_cmd import detect_local_sources, ingest_sources
from openjarvis.connectors.store import KnowledgeStore
notes_db = tmp_path / "NoteStore.sqlite"
_create_fake_notes_db(notes_db)
sources = detect_local_sources(
notes_db_path=notes_db,
imessage_db_path=tmp_path / "nonexistent.db",
obsidian_vault_path=None,
)
db_path = tmp_path / "knowledge.db"
store = KnowledgeStore(str(db_path))
total = ingest_sources(sources, store)
assert total > 0
assert store.count() > 0
store.close()
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/cli/test_deep_research_setup.py -v --tb=short
```
Expected: FAIL — `ModuleNotFoundError: No module named 'openjarvis.cli.deep_research_setup_cmd'`
- [ ] **Step 3: Create the CLI command module**
Create `src/openjarvis/cli/deep_research_setup_cmd.py`:
```python
"""``jarvis deep-research-setup`` — auto-detect local sources, ingest, and chat.
Walks the user through connecting local data sources (Apple Notes, iMessage,
Obsidian), ingesting them into a shared KnowledgeStore, and launching an
interactive Deep Research chat session with Qwen3.5 via Ollama.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import click
from rich.console import Console
from rich.table import Table
from openjarvis.connectors.pipeline import IngestionPipeline
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.connectors.sync_engine import SyncEngine
from openjarvis.core.config import DEFAULT_CONFIG_DIR
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_DEFAULT_NOTES_DB = (
Path.home()
/ "Library"
/ "Group Containers"
/ "group.com.apple.notes"
/ "NoteStore.sqlite"
)
_DEFAULT_IMESSAGE_DB = Path.home() / "Library" / "Messages" / "chat.db"
_OLLAMA_MODEL = "qwen3.5:4b"
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
def detect_local_sources(
*,
notes_db_path: Optional[Path] = None,
imessage_db_path: Optional[Path] = None,
obsidian_vault_path: Optional[Path] = None,
) -> List[Dict[str, Any]]:
"""Return a list of available local sources with their config.
Each entry is a dict with keys: ``connector_id``, ``display_name``,
``config`` (kwargs for the connector constructor).
"""
sources: List[Dict[str, Any]] = []
notes_path = notes_db_path or _DEFAULT_NOTES_DB
if notes_path.exists():
sources.append({
"connector_id": "apple_notes",
"display_name": "Apple Notes",
"config": {"db_path": str(notes_path)},
})
imessage_path = imessage_db_path or _DEFAULT_IMESSAGE_DB
if imessage_path.exists():
sources.append({
"connector_id": "imessage",
"display_name": "iMessage",
"config": {"db_path": str(imessage_path)},
})
if obsidian_vault_path and obsidian_vault_path.is_dir():
sources.append({
"connector_id": "obsidian",
"display_name": "Obsidian / Markdown",
"config": {"vault_path": str(obsidian_vault_path)},
})
return sources
# ---------------------------------------------------------------------------
# Ingestion
# ---------------------------------------------------------------------------
def _instantiate_connector(connector_id: str, config: Dict[str, Any]) -> Any:
"""Lazily import and instantiate a connector by ID."""
if connector_id == "apple_notes":
from openjarvis.connectors.apple_notes import AppleNotesConnector
return AppleNotesConnector(db_path=config.get("db_path", ""))
elif connector_id == "imessage":
from openjarvis.connectors.imessage import IMessageConnector
return IMessageConnector(db_path=config.get("db_path", ""))
elif connector_id == "obsidian":
from openjarvis.connectors.obsidian import ObsidianConnector
return ObsidianConnector(vault_path=config.get("vault_path", ""))
else:
msg = f"Unknown connector: {connector_id}"
raise ValueError(msg)
def ingest_sources(
sources: List[Dict[str, Any]],
store: KnowledgeStore,
) -> int:
"""Connect and ingest all sources into the KnowledgeStore.
Returns total chunks indexed across all sources.
"""
pipeline = IngestionPipeline(store)
engine = SyncEngine(pipeline)
total = 0
for src in sources:
connector = _instantiate_connector(src["connector_id"], src["config"])
chunks = engine.sync(connector)
total += chunks
return total
# ---------------------------------------------------------------------------
# Chat launch
# ---------------------------------------------------------------------------
def _launch_chat(store: KnowledgeStore, console: Console) -> None:
"""Start an interactive Deep Research chat session."""
from openjarvis.agents.deep_research import DeepResearchAgent
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
console.print("\n[bold]Setting up Deep Research agent...[/bold]")
# Engine
engine = OllamaEngine()
if not engine.health():
console.print(
"[red]Ollama is not running.[/red] Start it with: "
"[bold]ollama serve[/bold]"
)
return
models = engine.list_models()
if _OLLAMA_MODEL not in models and f"{_OLLAMA_MODEL}:latest" not in models:
# Check without tag too
base_name = _OLLAMA_MODEL.split(":")[0]
matching = [m for m in models if m.startswith(base_name)]
if not matching:
console.print(
f"[yellow]Model {_OLLAMA_MODEL} not found.[/yellow] "
f"Pull it with: [bold]ollama pull {_OLLAMA_MODEL}[/bold]"
)
return
# Retriever + tool
retriever = TwoStageRetriever(store)
search_tool = KnowledgeSearchTool(retriever=retriever)
# Agent
agent = DeepResearchAgent(
engine=engine,
model=_OLLAMA_MODEL,
tools=[search_tool],
interactive=True,
)
console.print(
f"[green]Ready![/green] Using [bold]{_OLLAMA_MODEL}[/bold] via Ollama.\n"
"Type your research question. Type [bold]/quit[/bold] to exit.\n"
)
# REPL
while True:
try:
query = console.input("[bold blue]research>[/bold blue] ").strip()
except (EOFError, KeyboardInterrupt):
break
if not query:
continue
if query.lower() in ("/quit", "/exit", "quit", "exit"):
break
try:
result = agent.run(query)
console.print(f"\n{result.content}\n")
if result.metadata and result.metadata.get("sources"):
console.print("[dim]Sources:[/dim]")
for s in result.metadata["sources"]:
console.print(f" [dim]- {s}[/dim]")
console.print()
except Exception as exc: # noqa: BLE001
console.print(f"[red]Error: {exc}[/red]\n")
# ---------------------------------------------------------------------------
# CLI command
# ---------------------------------------------------------------------------
@click.command("deep-research-setup")
@click.option(
"--obsidian-vault",
type=click.Path(exists=True, file_okay=False),
default=None,
help="Path to an Obsidian vault directory.",
)
@click.option("--skip-chat", is_flag=True, help="Ingest only, don't launch chat.")
def deep_research_setup(obsidian_vault: Optional[str], skip_chat: bool) -> None:
"""Auto-detect local data sources, ingest, and launch Deep Research chat."""
console = Console()
console.print("\n[bold]Deep Research Setup[/bold]\n")
# 1. Detect
vault_path = Path(obsidian_vault) if obsidian_vault else None
sources = detect_local_sources(obsidian_vault_path=vault_path)
if not sources:
console.print(
"[yellow]No local data sources detected.[/yellow]\n"
"On macOS, ensure Full Disk Access is granted in "
"System Settings > Privacy & Security."
)
sys.exit(1)
# 2. Confirm
table = Table(title="Detected Sources")
table.add_column("Source", style="bold")
table.add_column("Status", style="green")
for src in sources:
table.add_row(src["display_name"], "ready")
console.print(table)
console.print()
if not click.confirm("Ingest these sources?", default=True):
sys.exit(0)
# 3. Ingest
db_path = DEFAULT_CONFIG_DIR / "knowledge.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
store = KnowledgeStore(str(db_path))
console.print("\n[bold]Ingesting...[/bold]")
for src in sources:
connector = _instantiate_connector(src["connector_id"], src["config"])
pipeline = IngestionPipeline(store)
engine = SyncEngine(pipeline)
chunks = engine.sync(connector)
console.print(f" {src['display_name']}: [green]{chunks} chunks[/green]")
console.print(f"\n[bold green]Done![/bold green] {store.count()} total chunks in {db_path}\n")
# 4. Chat
if skip_chat:
return
_launch_chat(store, console)
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
uv run pytest tests/cli/test_deep_research_setup.py -v --tb=short
```
Expected: All 4 tests PASS.
- [ ] **Step 5: Register the command in CLI __init__.py**
In `src/openjarvis/cli/__init__.py`, add the import and registration alongside the existing commands.
Add the import near the other CLI imports:
```python
from openjarvis.cli.deep_research_setup_cmd import deep_research_setup
```
Add the registration near the other `cli.add_command()` calls:
```python
cli.add_command(deep_research_setup, "deep-research-setup")
```
- [ ] **Step 6: Verify the command is discoverable**
```bash
uv run jarvis --help | grep deep-research
```
Expected: `deep-research-setup` appears in the command list.
- [ ] **Step 7: Commit**
```bash
git add src/openjarvis/cli/deep_research_setup_cmd.py tests/cli/test_deep_research_setup.py src/openjarvis/cli/__init__.py
git commit -m "feat: add jarvis deep-research-setup CLI command"
```
---
### Task 3: Wire connectors API router into FastAPI app
**Files:**
- Modify: `src/openjarvis/server/app.py`
The `connectors_router.py` already has all 6 endpoints implemented. It was never registered in the FastAPI app. This is a one-line fix.
- [ ] **Step 1: Read app.py to find where routers are included**
```bash
grep -n "include_router" src/openjarvis/server/app.py
```
Identify the section where `app.include_router(router)` and `app.include_router(dashboard_router)` are called.
- [ ] **Step 2: Add the connectors router import and registration**
Add the import at the top of `app.py` with the other router imports:
```python
from openjarvis.server.connectors_router import create_connectors_router
```
Add the router registration in the same block as the other `app.include_router()` calls:
```python
app.include_router(create_connectors_router())
```
- [ ] **Step 3: Verify the endpoints are now exposed**
```bash
uv run python3 -c "
from openjarvis.server.app import create_app
from openjarvis.engine.ollama import OllamaEngine
engine = OllamaEngine()
app = create_app(engine, 'test')
routes = [r.path for r in app.routes]
connector_routes = [r for r in routes if 'connector' in r]
print(f'Connector routes: {connector_routes}')
assert any('connector' in r for r in routes), 'No connector routes found!'
print('PASS: Connectors router is registered')
"
```
Expected: Lists `/connectors`, `/connectors/{connector_id}`, etc. and prints PASS.
- [ ] **Step 4: Commit**
```bash
git add src/openjarvis/server/app.py
git commit -m "fix: register connectors API router in FastAPI app"
```
---
### Task 4: End-to-end test with real data
**Files:** None (manual testing)
This is the live validation. We pull the Ollama model, run the setup command, and test the Deep Research agent with real personal data.
- [ ] **Step 1: Ensure Ollama is running and pull the model**
```bash
ollama list
ollama pull qwen3.5:4b
```
Expected: Model downloaded. If Ollama isn't running, start it with `ollama serve &`.
- [ ] **Step 2: Run the deep research setup**
```bash
uv run jarvis deep-research-setup
```
Expected output:
```
Deep Research Setup
Detected Sources
┌──────────────┬────────┐
│ Source │ Status │
├──────────────┼────────┤
│ Apple Notes │ ready │
│ iMessage │ ready │
└──────────────┴────────┘
Ingest these sources? [Y/n]: Y
Ingesting...
Apple Notes: ~100 chunks
iMessage: ~52000 chunks
Done! ~52100 total chunks in ~/.openjarvis/knowledge.db
Setting up Deep Research agent...
Ready! Using qwen3.5:4b via Ollama.
Type your research question. Type /quit to exit.
research>
```
If iMessage ingestion is too slow (52K messages), you can Ctrl+C and re-run with a smaller dataset — we can add a `--limit` flag later.
- [ ] **Step 3: Test retrieval quality with 3 queries**
At the `research>` prompt, try:
1. A person's name who appears in both Apple Notes and iMessage
2. A topic from your notes (e.g. "Georgia Tech" which appeared in Apple Notes)
3. A time-bounded query like "what messages did I send last week"
Evaluate: Does the agent use `knowledge_search`? Does it cite sources? Does it make multiple hops?
- [ ] **Step 4: Document results**
Note: query, response quality, number of tool calls, latency, and any errors. This informs whether we need a larger model or retrieval tuning.
---
### Task 5: Smoke test wizard UI
**Files:**
- None (manual testing)
- [ ] **Step 1: Start the FastAPI server**
```bash
uv run jarvis serve --host 127.0.0.1 --port 8000
```
- [ ] **Step 2: Open the frontend dev server**
In a second terminal:
```bash
cd frontend && npm run dev
```
Expected: Vite dev server starts at `http://localhost:5173`.
- [ ] **Step 3: Walk through the wizard**
Open `http://localhost:5173` in a browser. The setup wizard should:
1. Show the source picker with Apple Notes, iMessage, etc.
2. Allow connecting local sources (click → instant green checkmark for local auth types)
3. Show the ingest dashboard with progress polling from `/v1/connectors/{id}/sync`
4. Land on the "Ready" screen
Note any errors — the frontend was built on a remote server and never build-tested until now.
- [ ] **Step 4: Commit any fixes needed**
If frontend or API fixes are required, commit them:
```bash
git add -A
git commit -m "fix: wizard UI smoke test fixes"
```
---
### Task 6: Run full test suite (regression check)
**Files:** None
- [ ] **Step 1: Run all connector + agent tests**
```bash
uv run pytest tests/connectors/ tests/agents/test_deep_research.py tests/agents/test_deep_research_integration.py tests/agents/test_channel_agent.py tests/agents/test_channel_agent_integration.py tests/cli/test_deep_research_setup.py -v --tb=short
```
Expected: All tests PASS (220+ existing + 4 new from Task 2).
- [ ] **Step 2: Run linter**
```bash
uv run ruff check src/openjarvis/cli/deep_research_setup_cmd.py src/openjarvis/server/app.py src/openjarvis/connectors/apple_notes.py
```
Expected: No errors.
- [ ] **Step 3: Commit and push**
```bash
git push origin feat/deep-research-setup
```
@@ -1,724 +0,0 @@
# Outlook Connector + Token Source Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Generalize Gmail IMAP to support Outlook, and extend `jarvis deep-research-setup` to detect, connect, and ingest token-based sources (Slack, Notion, Granola, Gmail IMAP, Outlook) alongside local sources.
**Architecture:** Add `imap_host` parameter to `GmailIMAPConnector`, create a thin `OutlookConnector` subclass, then extend the CLI command with `detect_token_sources()` and an interactive connect prompt that saves credentials and ingests all sources in one flow.
**Tech Stack:** Python 3.10+, imaplib, Click, Rich, SQLite/FTS5, pytest
---
### Task 1: Generalize GmailIMAPConnector with `imap_host` parameter
**Files:**
- Modify: `src/openjarvis/connectors/gmail_imap.py`
- Modify: `tests/connectors/test_gmail_imap.py`
- [ ] **Step 1: Read the current test file to understand existing test patterns**
```bash
cat tests/connectors/test_gmail_imap.py
```
- [ ] **Step 2: Add `imap_host` parameter to `GmailIMAPConnector.__init__`**
In `src/openjarvis/connectors/gmail_imap.py`, add a class attribute and constructor parameter:
```python
# Add class attribute after line 84 (after display_name):
_default_imap_host = "imap.gmail.com"
# Change __init__ signature to add imap_host:
def __init__(
self,
email_address: str = "",
app_password: str = "",
credentials_path: str = "",
*,
imap_host: str = "",
max_messages: int = 500,
) -> None:
self._email = email_address
self._password = app_password
self._credentials_path = credentials_path or _DEFAULT_CREDENTIALS_PATH
self._imap_host = imap_host or self._default_imap_host
self._max_messages = max_messages
self._items_synced = 0
self._items_total = 0
```
- [ ] **Step 3: Update `sync()` to use `self._imap_host`**
In `src/openjarvis/connectors/gmail_imap.py`, line 147, change:
```python
# Before:
imap = imaplib.IMAP4_SSL("imap.gmail.com")
# After:
imap = imaplib.IMAP4_SSL(self._imap_host)
```
- [ ] **Step 4: Run existing tests to verify nothing breaks**
```bash
uv run pytest tests/connectors/test_gmail_imap.py -v --tb=short
```
Expected: All existing tests PASS (they mock IMAP so the host doesn't matter).
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/connectors/gmail_imap.py
git commit -m "feat: add imap_host parameter to GmailIMAPConnector"
```
---
### Task 2: Create Outlook connector
**Files:**
- Create: `src/openjarvis/connectors/outlook.py`
- Create: `tests/connectors/test_outlook.py`
- [ ] **Step 1: Write the test file**
Create `tests/connectors/test_outlook.py`:
```python
"""Tests for OutlookConnector — thin subclass of GmailIMAPConnector."""
from __future__ import annotations
from pathlib import Path
import pytest
from openjarvis.core.registry import ConnectorRegistry
def test_outlook_registered() -> None:
"""OutlookConnector is discoverable via ConnectorRegistry."""
from openjarvis.connectors.outlook import OutlookConnector
ConnectorRegistry.register_value("outlook", OutlookConnector)
assert ConnectorRegistry.contains("outlook")
cls = ConnectorRegistry.get("outlook")
assert cls.connector_id == "outlook"
assert cls.display_name == "Outlook / Microsoft 365"
def test_outlook_uses_correct_imap_host() -> None:
"""OutlookConnector defaults to outlook.office365.com."""
from openjarvis.connectors.outlook import OutlookConnector
conn = OutlookConnector()
assert conn._imap_host == "outlook.office365.com"
def test_outlook_auth_url() -> None:
"""auth_url() points to Microsoft security page."""
from openjarvis.connectors.outlook import OutlookConnector
conn = OutlookConnector()
assert "microsoft.com" in conn.auth_url()
def test_outlook_handle_callback(tmp_path: Path) -> None:
"""handle_callback saves email:password credentials."""
from openjarvis.connectors.outlook import OutlookConnector
from openjarvis.connectors.oauth import load_tokens
creds_path = str(tmp_path / "outlook.json")
conn = OutlookConnector(credentials_path=creds_path)
conn.handle_callback("user@outlook.com:mypassword123")
tokens = load_tokens(creds_path)
assert tokens is not None
assert tokens["email"] == "user@outlook.com"
assert tokens["password"] == "mypassword123"
def test_outlook_is_connected(tmp_path: Path) -> None:
"""is_connected returns True when credentials file has email+password."""
from openjarvis.connectors.outlook import OutlookConnector
creds_path = str(tmp_path / "outlook.json")
conn = OutlookConnector(credentials_path=creds_path)
assert conn.is_connected() is False
conn.handle_callback("user@outlook.com:pass")
assert conn.is_connected() is True
def test_outlook_sync_source_is_outlook(tmp_path: Path) -> None:
"""Documents yielded by sync() have source='outlook' and doc_id prefix 'outlook:'."""
from unittest.mock import MagicMock, patch
from openjarvis.connectors.outlook import OutlookConnector
creds_path = str(tmp_path / "outlook.json")
conn = OutlookConnector(credentials_path=creds_path)
conn.handle_callback("user@outlook.com:pass")
# Mock IMAP to return one email
mock_imap = MagicMock()
mock_imap.login.return_value = ("OK", [])
mock_imap.select.return_value = ("OK", [])
mock_imap.search.return_value = ("OK", [b"1"])
raw_email = (
b"From: sender@test.com\r\n"
b"To: user@outlook.com\r\n"
b"Subject: Test Email\r\n"
b"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n"
b"Message-ID: <test123@test.com>\r\n"
b"\r\n"
b"Hello from Outlook test"
)
mock_imap.fetch.return_value = ("OK", [(b"1", raw_email)])
mock_imap.logout.return_value = ("OK", [])
with patch("openjarvis.connectors.gmail_imap.imaplib") as mock_imaplib:
mock_imaplib.IMAP4_SSL.return_value = mock_imap
mock_imaplib.IMAP4 = type(mock_imap)
docs = list(conn.sync())
assert len(docs) == 1
assert docs[0].source == "outlook"
assert docs[0].doc_id.startswith("outlook:")
assert docs[0].title == "Test Email"
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/connectors/test_outlook.py -v --tb=short
```
Expected: FAIL — `ModuleNotFoundError: No module named 'openjarvis.connectors.outlook'`
- [ ] **Step 3: Create the Outlook connector**
Create `src/openjarvis/connectors/outlook.py`:
```python
"""Outlook / Microsoft 365 connector — reads email via IMAP with app password.
Thin subclass of GmailIMAPConnector that defaults to the Outlook IMAP host
and relabels documents with source='outlook'.
Setup: enable 2FA on your Microsoft account, then generate an app password
at https://account.microsoft.com/security
"""
from __future__ import annotations
from typing import Iterator, Optional
from datetime import datetime
from openjarvis.connectors._stubs import Document
from openjarvis.connectors.gmail_imap import GmailIMAPConnector
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.core.registry import ConnectorRegistry
_DEFAULT_CREDENTIALS_PATH = str(
DEFAULT_CONFIG_DIR / "connectors" / "outlook.json"
)
@ConnectorRegistry.register("outlook")
class OutlookConnector(GmailIMAPConnector):
"""Outlook connector using IMAP + app password.
Inherits all IMAP logic from :class:`GmailIMAPConnector` and overrides
the IMAP host, credential path, auth URL, and document source labels.
"""
connector_id = "outlook"
display_name = "Outlook / Microsoft 365"
_default_imap_host = "outlook.office365.com"
def __init__(
self,
email_address: str = "",
app_password: str = "",
credentials_path: str = "",
*,
max_messages: int = 500,
) -> None:
super().__init__(
email_address,
app_password,
credentials_path or _DEFAULT_CREDENTIALS_PATH,
max_messages=max_messages,
)
def auth_url(self) -> str:
"""Return the Microsoft account security page for app passwords."""
return "https://account.microsoft.com/security"
def sync(
self,
*,
since: Optional[datetime] = None,
cursor: Optional[str] = None,
) -> Iterator[Document]:
"""Sync emails and relabel with source='outlook'."""
for doc in super().sync(since=since, cursor=cursor):
doc.source = "outlook"
doc.doc_id = doc.doc_id.replace("gmail:", "outlook:", 1)
yield doc
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
uv run pytest tests/connectors/test_outlook.py -v --tb=short
```
Expected: All 6 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/openjarvis/connectors/outlook.py tests/connectors/test_outlook.py
git commit -m "feat: add Outlook connector as IMAP subclass"
```
---
### Task 3: Extend deep-research-setup with token source detection and interactive connect
**Files:**
- Modify: `src/openjarvis/cli/deep_research_setup_cmd.py`
- Modify: `tests/cli/test_deep_research_setup.py`
- [ ] **Step 1: Write tests for token source detection**
Append to `tests/cli/test_deep_research_setup.py`:
```python
def test_detect_token_sources_finds_connected(tmp_path: Path) -> None:
"""detect_token_sources finds sources with valid credential files."""
from openjarvis.cli.deep_research_setup_cmd import detect_token_sources
connectors_dir = tmp_path / "connectors"
connectors_dir.mkdir()
(connectors_dir / "slack.json").write_text('{"token": "xoxb-test"}')
(connectors_dir / "notion.json").write_text('{"token": "ntn_test"}')
sources = detect_token_sources(connectors_dir=connectors_dir)
ids = [s["connector_id"] for s in sources]
assert "slack" in ids
assert "notion" in ids
def test_detect_token_sources_skips_empty(tmp_path: Path) -> None:
"""detect_token_sources skips files with empty or invalid JSON."""
from openjarvis.cli.deep_research_setup_cmd import detect_token_sources
connectors_dir = tmp_path / "connectors"
connectors_dir.mkdir()
(connectors_dir / "slack.json").write_text("{}")
(connectors_dir / "notion.json").write_text("invalid json")
sources = detect_token_sources(connectors_dir=connectors_dir)
assert len(sources) == 0
def test_detect_token_sources_empty_dir(tmp_path: Path) -> None:
"""detect_token_sources returns empty list when no credential files exist."""
from openjarvis.cli.deep_research_setup_cmd import detect_token_sources
connectors_dir = tmp_path / "connectors"
connectors_dir.mkdir()
sources = detect_token_sources(connectors_dir=connectors_dir)
assert len(sources) == 0
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/cli/test_deep_research_setup.py::test_detect_token_sources_finds_connected -v --tb=short
```
Expected: FAIL — `ImportError: cannot import name 'detect_token_sources'`
- [ ] **Step 3: Add `detect_token_sources` and `_TOKEN_SOURCES` to deep_research_setup_cmd.py**
Add after the `detect_local_sources` function (around line 80):
```python
# ---------------------------------------------------------------------------
# Token-based source detection
# ---------------------------------------------------------------------------
_TOKEN_SOURCES = [
{
"connector_id": "gmail_imap",
"display_name": "Gmail (IMAP)",
"creds_file": "gmail_imap.json",
"prompt_label": "email:app_password",
},
{
"connector_id": "outlook",
"display_name": "Outlook / Microsoft 365",
"creds_file": "outlook.json",
"prompt_label": "email:app_password",
},
{
"connector_id": "slack",
"display_name": "Slack",
"creds_file": "slack.json",
"prompt_label": "Bot token (xoxb-... or xoxe-...)",
},
{
"connector_id": "notion",
"display_name": "Notion",
"creds_file": "notion.json",
"prompt_label": "Integration token (ntn_...)",
},
{
"connector_id": "granola",
"display_name": "Granola",
"creds_file": "granola.json",
"prompt_label": "API key (grn_...)",
},
]
def detect_token_sources(
*,
connectors_dir: Optional[Path] = None,
) -> List[Dict[str, Any]]:
"""Return token-based sources that already have valid credentials.
Scans ``~/.openjarvis/connectors/`` for known credential files.
"""
cdir = connectors_dir or (DEFAULT_CONFIG_DIR / "connectors")
sources: List[Dict[str, Any]] = []
for ts in _TOKEN_SOURCES:
creds_file = cdir / ts["creds_file"]
if not creds_file.exists():
continue
try:
data = json.loads(creds_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
# Must have at least one non-empty value
if not data or not any(v for v in data.values() if v):
continue
sources.append({
"connector_id": ts["connector_id"],
"display_name": ts["display_name"],
"config": {},
})
return sources
```
Also add `import json` at the top of the file (add to existing imports section).
- [ ] **Step 4: Run token detection tests**
```bash
uv run pytest tests/cli/test_deep_research_setup.py -v --tb=short
```
Expected: All 7 tests PASS (4 old + 3 new).
- [ ] **Step 5: Add `_prompt_connect_sources` function and update `_instantiate_connector`**
Add the interactive connect function after `detect_token_sources`:
```python
def _prompt_connect_sources(console: Console) -> List[Dict[str, Any]]:
"""Interactively prompt the user to connect token-based sources."""
connected: List[Dict[str, Any]] = []
cdir = DEFAULT_CONFIG_DIR / "connectors"
cdir.mkdir(parents=True, exist_ok=True)
while True:
# Show unconnected sources
unconnected = [
ts for ts in _TOKEN_SOURCES
if not (cdir / ts["creds_file"]).exists()
]
if not unconnected:
console.print("[dim]All token sources already connected.[/dim]")
break
if not click.confirm("Connect additional sources?", default=False):
break
names = [ts["connector_id"] for ts in unconnected]
labels = [f"{ts['display_name']} ({ts['connector_id']})" for ts in unconnected]
console.print("Available:")
for label in labels:
console.print(f" {label}")
choice = click.prompt(
"Which source?",
type=click.Choice(names, case_sensitive=False),
)
ts = next(t for t in unconnected if t["connector_id"] == choice)
token = click.prompt(f"Paste your {ts['prompt_label']}")
# Save credentials via connector's handle_callback
connector = _instantiate_connector(choice, {})
connector.handle_callback(token.strip())
console.print(f" [green]{ts['display_name']}: connected![/green]")
connected.append({
"connector_id": choice,
"display_name": ts["display_name"],
"config": {},
})
return connected
```
Update `_instantiate_connector` to handle all connector types:
```python
def _instantiate_connector(connector_id: str, config: Dict[str, Any]) -> Any:
"""Lazily import and instantiate a connector by ID."""
if connector_id == "apple_notes":
from openjarvis.connectors.apple_notes import AppleNotesConnector
return AppleNotesConnector(db_path=config.get("db_path", ""))
elif connector_id == "imessage":
from openjarvis.connectors.imessage import IMessageConnector
return IMessageConnector(db_path=config.get("db_path", ""))
elif connector_id == "obsidian":
from openjarvis.connectors.obsidian import ObsidianConnector
return ObsidianConnector(vault_path=config.get("vault_path", ""))
elif connector_id == "gmail_imap":
from openjarvis.connectors.gmail_imap import GmailIMAPConnector
return GmailIMAPConnector()
elif connector_id == "outlook":
from openjarvis.connectors.outlook import OutlookConnector
return OutlookConnector()
elif connector_id == "slack":
from openjarvis.connectors.slack_connector import SlackConnector
return SlackConnector()
elif connector_id == "notion":
from openjarvis.connectors.notion import NotionConnector
return NotionConnector()
elif connector_id == "granola":
from openjarvis.connectors.granola import GranolaConnector
return GranolaConnector()
else:
msg = f"Unknown connector: {connector_id}"
raise ValueError(msg)
```
- [ ] **Step 6: Update the `deep_research_setup` Click command to use token sources**
Replace the body of the `deep_research_setup` function with:
```python
@click.command("deep-research-setup")
@click.option(
"--obsidian-vault",
type=click.Path(exists=True, file_okay=False),
default=None,
help="Path to an Obsidian vault directory.",
)
@click.option("--skip-chat", is_flag=True, help="Ingest only, don't launch chat.")
def deep_research_setup(obsidian_vault: Optional[str], skip_chat: bool) -> None:
"""Auto-detect local data sources, ingest, and launch Deep Research chat."""
console = Console()
console.print("\n[bold]Deep Research Setup[/bold]\n")
# 1. Detect local sources
vault_path = Path(obsidian_vault) if obsidian_vault else None
local_sources = detect_local_sources(obsidian_vault_path=vault_path)
# 2. Detect already-connected token sources
token_sources = detect_token_sources()
all_sources = local_sources + token_sources
# 3. Show what we found
if all_sources:
table = Table(title="Detected Sources")
table.add_column("Source", style="bold")
table.add_column("Type", style="dim")
table.add_column("Status", style="green")
for src in local_sources:
table.add_row(src["display_name"], "local", "ready")
for src in token_sources:
table.add_row(src["display_name"], "token", "connected")
console.print(table)
console.print()
# 4. Offer to connect new token sources
newly_connected = _prompt_connect_sources(console)
all_sources.extend(newly_connected)
if not all_sources:
console.print(
"[yellow]No data sources detected or connected.[/yellow]\n"
"On macOS, ensure Full Disk Access is granted in "
"System Settings > Privacy & Security."
)
sys.exit(1)
# 5. Confirm and ingest
if not click.confirm("Ingest these sources?", default=True):
sys.exit(0)
db_path = DEFAULT_CONFIG_DIR / "knowledge.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
store = KnowledgeStore(str(db_path))
console.print("\n[bold]Ingesting...[/bold]")
for src in all_sources:
try:
connector = _instantiate_connector(
src["connector_id"], src["config"],
)
pipeline = IngestionPipeline(store)
engine = SyncEngine(pipeline)
chunks = engine.sync(connector)
console.print(
f" {src['display_name']}: [green]{chunks} chunks[/green]"
)
except Exception as exc: # noqa: BLE001
console.print(
f" {src['display_name']}: [red]error: {exc}[/red]"
)
total = store.count()
console.print(
f"\n[bold green]Done![/bold green] {total} total chunks in {db_path}\n"
)
# 6. Chat
if skip_chat:
return
_launch_chat(store, console)
```
- [ ] **Step 7: Run all tests**
```bash
uv run pytest tests/cli/test_deep_research_setup.py tests/connectors/test_outlook.py -v --tb=short
```
Expected: All tests PASS.
- [ ] **Step 8: Lint**
```bash
uv run ruff check src/openjarvis/cli/deep_research_setup_cmd.py src/openjarvis/connectors/outlook.py src/openjarvis/connectors/gmail_imap.py
```
Expected: No errors.
- [ ] **Step 9: Commit**
```bash
git add src/openjarvis/cli/deep_research_setup_cmd.py tests/cli/test_deep_research_setup.py
git commit -m "feat: extend deep-research-setup with token source detection and interactive connect"
```
---
### Task 4: Live test — connect all sources and ingest
**Files:** None (manual testing)
- [ ] **Step 1: Connect Slack**
```bash
uv run jarvis connect slack
```
Paste: your Slack bot token (`xoxb-...` or `xoxe-...`)
- [ ] **Step 2: Connect Notion**
```bash
uv run jarvis connect notion
```
Paste: your Notion integration token (`ntn_...`)
- [ ] **Step 3: Connect Granola**
```bash
uv run jarvis connect granola
```
Paste: your Granola API key (`grn_...`)
- [ ] **Step 4: Connect Gmail IMAP**
```bash
uv run jarvis connect gmail_imap
```
Paste: `your-email@gmail.com:qpde kebj evhy zljc`
(Replace `your-email@gmail.com` with your actual Gmail address.)
- [ ] **Step 5: Run deep-research-setup and verify all sources ingest**
```bash
uv run jarvis deep-research-setup --skip-chat
```
Expected output:
```
Deep Research Setup
Detected Sources
┌───────────────────────┬───────┬───────────┐
│ Source │ Type │ Status │
├───────────────────────┼───────┼───────────┤
│ Apple Notes │ local │ ready │
│ iMessage │ local │ ready │
│ Gmail (IMAP) │ token │ connected │
│ Slack │ token │ connected │
│ Notion │ token │ connected │
│ Granola │ token │ connected │
└───────────────────────┴───────┴───────────┘
Ingest these sources? [Y/n]: Y
Ingesting...
Apple Notes: ~100 chunks
iMessage: ~52000 chunks
Gmail (IMAP): N chunks
Slack: N chunks
Notion: N chunks
Granola: N chunks
Done! N total chunks in ~/.openjarvis/knowledge.db
```
- [ ] **Step 6: Test the research agent with cross-source queries**
```bash
uv run jarvis deep-research-setup
```
At the `research>` prompt, try:
1. A query that should span emails + messages
2. A query about meeting notes (Granola data)
3. A query about a Notion page
- [ ] **Step 7: Push**
```bash
git push origin feat/deep-research-setup
```
@@ -1,722 +0,0 @@
# Channel Gateway → DeepResearch Wiring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire the ChannelBridge (from PR #78, now merged) to route incoming messages from Slack, iMessage/BlueBubbles, WhatsApp, and SMS to the DeepResearchAgent, and add an AppleScript-based iMessage daemon for direct iPhone-to-agent messaging without external services.
**Architecture:** Modify `ChannelBridge.handle_incoming()` to use DeepResearchAgent instead of generic `JarvisSystem.ask()`. Add an `IMessageDaemon` that polls `chat.db` for new messages and routes them through the bridge. Add `jarvis channels` CLI commands for setup and lifecycle.
**Tech Stack:** Python 3.10+, sqlite3, subprocess (AppleScript), Click, asyncio, pytest
---
### Task 1: Wire ChannelBridge to DeepResearchAgent
**Files:**
- Modify: `src/openjarvis/server/channel_bridge.py`
- Create: `tests/server/test_channel_bridge_deep_research.py`
- [ ] **Step 1: Read `channel_bridge.py` to find `_handle_chat` method**
The `_handle_chat` method currently calls `self._system.ask()`. We need it to use DeepResearchAgent when available.
- [ ] **Step 2: Write the test**
Create `tests/server/test_channel_bridge_deep_research.py`:
```python
"""Test ChannelBridge routes to DeepResearchAgent."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
def test_handle_chat_uses_deep_research_agent() -> None:
"""When a DeepResearch agent is configured, route through it."""
from openjarvis.server.channel_bridge import ChannelBridge
from openjarvis.server.session_store import SessionStore
mock_agent = MagicMock()
mock_agent.run.return_value = MagicMock(
content="Found 3 results about Spain.",
)
bridge = ChannelBridge(
channels={},
session_store=SessionStore(db_path=":memory:"),
bus=MagicMock(),
deep_research_agent=mock_agent,
)
result = bridge.handle_incoming(
sender_id="+15551234567",
content="When was my last trip to Spain?",
channel_type="twilio",
)
assert "Spain" in result
mock_agent.run.assert_called_once()
def test_handle_chat_falls_back_to_system() -> None:
"""When no DeepResearch agent, fall back to system.ask()."""
from openjarvis.server.channel_bridge import ChannelBridge
from openjarvis.server.session_store import SessionStore
mock_system = MagicMock()
mock_system.ask.return_value = "Generic response"
bridge = ChannelBridge(
channels={},
session_store=SessionStore(db_path=":memory:"),
bus=MagicMock(),
system=mock_system,
)
result = bridge.handle_incoming(
sender_id="+15551234567",
content="Hello",
channel_type="twilio",
)
assert result == "Generic response"
mock_system.ask.assert_called_once()
```
- [ ] **Step 3: Modify `ChannelBridge.__init__` to accept `deep_research_agent`**
In `channel_bridge.py`, add `deep_research_agent` parameter to `__init__`:
```python
def __init__(
self,
channels: Dict[str, BaseChannel],
session_store: SessionStore,
bus: EventBus,
system: Any = None,
agent_manager: Any = None,
deep_research_agent: Any = None,
) -> None:
self._channels = channels
self._session_store = session_store
self._bus = bus
self._system = system
self._agent_manager = agent_manager
self._deep_research_agent = deep_research_agent
self._notification_timestamps: Dict[str, float] = {}
self._subscribe_notifications()
```
- [ ] **Step 4: Modify `_handle_chat` to use DeepResearchAgent first**
Find the `_handle_chat` method. Replace the body to try DeepResearchAgent first, fall back to system:
```python
def _handle_chat(
self,
sender_id: str,
content: str,
channel_type: str,
max_length: int = _DEFAULT_MAX_LENGTH,
) -> str:
"""Route chat to DeepResearchAgent or JarvisSystem."""
# Try DeepResearchAgent first
if self._deep_research_agent is not None:
try:
result = self._deep_research_agent.run(content)
response = result.content or "No results found."
except Exception as exc:
logger.error("DeepResearch agent failed: %s", exc)
response = f"Research error: {exc}"
elif self._system is not None:
response = self._system.ask(content)
else:
response = "No agent or system configured."
# Truncate if needed
if len(response) > max_length:
self._session_store.set_overflow(sender_id, response)
response = response[:max_length] + "\n\n(truncated — send /more for the rest)"
return response
```
- [ ] **Step 5: Run tests**
```bash
uv run pytest tests/server/test_channel_bridge_deep_research.py tests/server/test_channel_bridge.py -v --tb=short
```
Expected: All PASS (new tests + existing tests).
- [ ] **Step 6: Commit**
```bash
git add src/openjarvis/server/channel_bridge.py tests/server/test_channel_bridge_deep_research.py
git commit -m "feat: wire ChannelBridge to route messages to DeepResearchAgent"
```
---
### Task 2: Create iMessage AppleScript daemon
**Files:**
- Create: `src/openjarvis/channels/imessage_daemon.py`
- Create: `tests/channels/test_imessage_daemon.py`
- [ ] **Step 1: Write the test**
Create `tests/channels/test_imessage_daemon.py`:
```python
"""Tests for iMessage AppleScript daemon."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
def _create_fake_chat_db(db_path: Path) -> None:
"""Create a minimal chat.db with one message."""
conn = sqlite3.connect(str(db_path))
conn.executescript("""
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT, display_name TEXT
);
CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY, text TEXT, handle_id INTEGER,
date INTEGER, is_from_me INTEGER
);
""")
conn.execute("INSERT INTO handle VALUES (1, '+15551234567')")
conn.execute("INSERT INTO chat VALUES (1, '+15551234567', 'Test Chat')")
conn.execute("INSERT INTO chat_message_join VALUES (1, 1)")
conn.execute(
"INSERT INTO message VALUES (1, 'Hello agent', 1, 700000000000000000, 0)"
)
conn.commit()
conn.close()
def test_poll_new_messages(tmp_path: Path) -> None:
"""Daemon detects new messages after last_rowid."""
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
_create_fake_chat_db(db_path)
messages = poll_new_messages(
db_path=str(db_path),
last_rowid=0,
chat_identifier="+15551234567",
)
assert len(messages) == 1
assert messages[0]["text"] == "Hello agent"
assert messages[0]["rowid"] == 1
def test_poll_skips_old_messages(tmp_path: Path) -> None:
"""Daemon skips messages with ROWID <= last_rowid."""
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
_create_fake_chat_db(db_path)
messages = poll_new_messages(
db_path=str(db_path),
last_rowid=1,
chat_identifier="+15551234567",
)
assert len(messages) == 0
def test_poll_filters_by_chat(tmp_path: Path) -> None:
"""Daemon only returns messages from the designated chat."""
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
_create_fake_chat_db(db_path)
messages = poll_new_messages(
db_path=str(db_path),
last_rowid=0,
chat_identifier="+15559999999",
)
assert len(messages) == 0
def test_poll_skips_own_messages(tmp_path: Path) -> None:
"""Daemon ignores messages sent by the user (is_from_me=1)."""
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
conn = sqlite3.connect(str(db_path))
conn.executescript("""
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT, display_name TEXT
);
CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY, text TEXT, handle_id INTEGER,
date INTEGER, is_from_me INTEGER
);
""")
conn.execute("INSERT INTO handle VALUES (1, '+15551234567')")
conn.execute("INSERT INTO chat VALUES (1, '+15551234567', 'Test')")
conn.execute("INSERT INTO chat_message_join VALUES (1, 1)")
conn.execute(
"INSERT INTO message VALUES (1, 'My own msg', 1, 700000000000000000, 1)"
)
conn.commit()
conn.close()
messages = poll_new_messages(
db_path=str(db_path),
last_rowid=0,
chat_identifier="+15551234567",
)
assert len(messages) == 0
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/channels/test_imessage_daemon.py -v --tb=short
```
Expected: FAIL — `ModuleNotFoundError`
- [ ] **Step 3: Create the daemon module**
Create `src/openjarvis/channels/imessage_daemon.py`:
```python
"""iMessage daemon — polls chat.db and routes to DeepResearchAgent.
Monitors a designated iMessage conversation for new messages, routes
them to the agent, and sends responses back via AppleScript.
Requires macOS with Full Disk Access for chat.db reading and
Accessibility permission for AppleScript Messages control.
"""
from __future__ import annotations
import logging
import os
import signal
import sqlite3
import subprocess
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
_DEFAULT_DB_PATH = str(
Path.home() / "Library" / "Messages" / "chat.db"
)
_POLL_INTERVAL = 5 # seconds
_PID_FILE = str(Path.home() / ".openjarvis" / "imessage-agent.pid")
# -------------------------------------------------------------------
# Polling
# -------------------------------------------------------------------
def poll_new_messages(
*,
db_path: str = _DEFAULT_DB_PATH,
last_rowid: int = 0,
chat_identifier: str = "",
) -> List[Dict[str, Any]]:
"""Return new incoming messages since last_rowid.
Only returns messages where ``is_from_me = 0`` (incoming) and
that belong to the designated chat.
"""
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
except sqlite3.OperationalError:
return []
try:
rows = conn.execute(
"SELECT m.ROWID as rowid, m.text, m.date, c.chat_identifier "
"FROM message m "
"JOIN chat_message_join cmj ON cmj.message_id = m.ROWID "
"JOIN chat c ON c.ROWID = cmj.chat_id "
"WHERE m.ROWID > ? AND m.is_from_me = 0 AND m.text IS NOT NULL "
"AND c.chat_identifier = ? "
"ORDER BY m.ROWID ASC",
(last_rowid, chat_identifier),
).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
# -------------------------------------------------------------------
# AppleScript sending
# -------------------------------------------------------------------
def send_imessage(chat_identifier: str, message: str) -> bool:
"""Send an iMessage via AppleScript.
Returns True on success, False on failure.
"""
# Escape double quotes and backslashes for AppleScript
escaped = message.replace("\\", "\\\\").replace('"', '\\"')
script = (
f'tell application "Messages"\n'
f' set targetChat to a reference to chat id "{chat_identifier}"\n'
f' send "{escaped}" to targetChat\n'
f"end tell"
)
try:
subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=30,
)
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
logger.error("Failed to send iMessage via AppleScript")
return False
# -------------------------------------------------------------------
# Daemon loop
# -------------------------------------------------------------------
def run_daemon(
*,
chat_identifier: str,
db_path: str = _DEFAULT_DB_PATH,
handler: Any = None,
poll_interval: float = _POLL_INTERVAL,
max_iterations: int = 0,
) -> None:
"""Run the iMessage polling daemon.
Parameters
----------
chat_identifier:
The chat to monitor (phone number or email).
handler:
Callable that takes a message string and returns a response string.
If None, messages are logged but not responded to.
poll_interval:
Seconds between polls.
max_iterations:
Stop after this many iterations (0 = run forever).
"""
# Write PID file
pid_path = Path(_PID_FILE)
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text(str(os.getpid()))
# Find starting ROWID
last_rowid = _get_max_rowid(db_path)
logger.info(
"iMessage daemon started — monitoring %s from ROWID %d",
chat_identifier,
last_rowid,
)
running = True
def _stop(signum: int, frame: Any) -> None:
nonlocal running
running = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
iterations = 0
while running:
messages = poll_new_messages(
db_path=db_path,
last_rowid=last_rowid,
chat_identifier=chat_identifier,
)
for msg in messages:
last_rowid = msg["rowid"]
text = msg["text"]
logger.info("Received: %s", text[:100])
if handler is not None:
try:
response = handler(text)
if response:
send_imessage(chat_identifier, response)
except Exception:
logger.exception("Handler failed for message %d", msg["rowid"])
iterations += 1
if max_iterations and iterations >= max_iterations:
break
time.sleep(poll_interval)
# Cleanup PID file
if pid_path.exists():
pid_path.unlink()
logger.info("iMessage daemon stopped")
def _get_max_rowid(db_path: str) -> int:
"""Get the current max ROWID from chat.db."""
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
row = conn.execute("SELECT MAX(ROWID) FROM message").fetchone()
conn.close()
return row[0] or 0
except sqlite3.OperationalError:
return 0
def is_running() -> bool:
"""Check if the daemon is currently running."""
pid_path = Path(_PID_FILE)
if not pid_path.exists():
return False
try:
pid = int(pid_path.read_text().strip())
os.kill(pid, 0)
return True
except (ValueError, ProcessLookupError, PermissionError):
pid_path.unlink(missing_ok=True)
return False
def stop_daemon() -> bool:
"""Stop the running daemon. Returns True if stopped."""
pid_path = Path(_PID_FILE)
if not pid_path.exists():
return False
try:
pid = int(pid_path.read_text().strip())
os.kill(pid, signal.SIGTERM)
pid_path.unlink(missing_ok=True)
return True
except (ValueError, ProcessLookupError, PermissionError):
pid_path.unlink(missing_ok=True)
return False
```
- [ ] **Step 4: Run tests**
```bash
uv run pytest tests/channels/test_imessage_daemon.py -v --tb=short
```
Expected: 4/4 PASS.
- [ ] **Step 5: Lint + commit**
```bash
uv run ruff check src/openjarvis/channels/imessage_daemon.py
git add src/openjarvis/channels/imessage_daemon.py tests/channels/test_imessage_daemon.py
git commit -m "feat: add iMessage AppleScript daemon for iPhone-to-agent messaging"
```
---
### Task 3: Add `jarvis channels` CLI commands
**Files:**
- Create: `src/openjarvis/cli/channels_cmd.py`
- Modify: `src/openjarvis/cli/__init__.py`
- [ ] **Step 1: Create the CLI command module**
Create `src/openjarvis/cli/channels_cmd.py`:
```python
"""``jarvis channels`` — manage channel connections for the DeepResearch agent."""
from __future__ import annotations
import sys
from typing import Optional
import click
from rich.console import Console
from rich.table import Table
@click.group("channels")
def channels() -> None:
"""Manage messaging channels (iMessage, Slack, WhatsApp, SMS)."""
@channels.command("status")
def channels_status() -> None:
"""Show status of all configured channels."""
from openjarvis.channels.imessage_daemon import is_running
console = Console()
table = Table(title="Channel Status")
table.add_column("Channel", style="bold")
table.add_column("Status")
table.add_column("Details", style="dim")
# iMessage daemon
if is_running():
table.add_row("iMessage", "[green]running[/green]", "Polling chat.db")
else:
table.add_row("iMessage", "[dim]stopped[/dim]", "jarvis channels imessage-start")
console.print(table)
@channels.command("imessage-start")
@click.argument("chat_identifier")
@click.option("--background/--foreground", default=True, help="Run in background.")
def imessage_start(chat_identifier: str, background: bool) -> None:
"""Start the iMessage daemon for a chat.
CHAT_IDENTIFIER is the phone number or email to monitor
(e.g. +15551234567 or group chat name).
"""
from openjarvis.channels.imessage_daemon import is_running, run_daemon
console = Console()
if is_running():
console.print("[yellow]iMessage daemon is already running.[/yellow]")
return
if background:
import subprocess
proc = subprocess.Popen(
[
sys.executable, "-m", "openjarvis.channels.imessage_daemon",
"--chat", chat_identifier,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
console.print(
f"[green]iMessage daemon started[/green] (PID {proc.pid})\n"
f"Monitoring: {chat_identifier}\n"
f"Text this contact from your iPhone to chat with the agent."
)
else:
console.print(f"[green]Starting iMessage daemon[/green] — monitoring {chat_identifier}")
console.print("Press Ctrl+C to stop.\n")
from openjarvis.agents.deep_research import DeepResearchAgent
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
from openjarvis.tools.scan_chunks import ScanChunksTool
from openjarvis.tools.think import ThinkTool
engine = OllamaEngine()
store = KnowledgeStore()
retriever = TwoStageRetriever(store)
tools = [
KnowledgeSearchTool(retriever=retriever),
KnowledgeSQLTool(store=store),
ScanChunksTool(store=store, engine=engine, model="qwen3.5:4b"),
ThinkTool(),
]
agent = DeepResearchAgent(engine=engine, model="qwen3.5:4b", tools=tools)
def handler(text: str) -> str:
result = agent.run(text)
return result.content or "No results found."
run_daemon(
chat_identifier=chat_identifier,
handler=handler,
)
@channels.command("imessage-stop")
def imessage_stop() -> None:
"""Stop the iMessage daemon."""
from openjarvis.channels.imessage_daemon import stop_daemon
console = Console()
if stop_daemon():
console.print("[green]iMessage daemon stopped.[/green]")
else:
console.print("[dim]iMessage daemon is not running.[/dim]")
```
- [ ] **Step 2: Register in CLI __init__.py**
In `src/openjarvis/cli/__init__.py`, add:
```python
from openjarvis.cli.channels_cmd import channels
cli.add_command(channels, "channels")
```
- [ ] **Step 3: Verify**
```bash
uv run jarvis channels --help
uv run jarvis channels status
```
- [ ] **Step 4: Lint + commit**
```bash
uv run ruff check src/openjarvis/cli/channels_cmd.py
git add src/openjarvis/cli/channels_cmd.py src/openjarvis/cli/__init__.py
git commit -m "feat: add jarvis channels CLI commands for iMessage daemon lifecycle"
```
---
### Task 4: Run full test suite + push
**Files:** None
- [ ] **Step 1: Run all tests**
```bash
uv run pytest tests/connectors/ tests/agents/test_deep_research.py tests/agents/test_deep_research_integration.py tests/agents/test_channel_agent.py tests/agents/test_channel_agent_integration.py tests/cli/test_deep_research_setup.py tests/tools/test_knowledge_sql.py tests/tools/test_scan_chunks.py tests/server/test_channel_bridge.py tests/server/test_channel_bridge_deep_research.py tests/server/test_session_store.py tests/server/test_webhook_routes.py tests/server/test_auth_middleware.py tests/server/test_deep_research_tools_wiring.py tests/channels/test_twilio_sms.py tests/channels/test_imessage_daemon.py --ignore=tests/connectors/test_embedding_store.py -k "not cached_embeddings and not caches_new_embeddings" -q --tb=short
```
Expected: All PASS.
- [ ] **Step 2: Lint**
```bash
uv run ruff check src/openjarvis/channels/imessage_daemon.py src/openjarvis/server/channel_bridge.py src/openjarvis/cli/channels_cmd.py
```
- [ ] **Step 3: Push to both branches**
```bash
git push origin feat/deep-research-setup
git push origin feat/deep-research-setup:feat/mobile-channel-gateway --force-with-lease
```
@@ -1,672 +0,0 @@
# Channels + Messaging Tabs Implementation Plan (v2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the existing Channels tab with two tabs — **Channels** (data sources with chunk counts and inline "+ Add" setup) and **Messaging** (clear "Text this number from your iPhone" instructions) — on every agent detail page.
**Architecture:** Refactor the existing `ChannelsTab` component in `AgentsPage.tsx` into two separate components. The Channels tab calls existing connector API endpoints + a new chunk-count endpoint. The Messaging tab reuses the existing channel binding API. The `StepByStepPanel` from `SourceConnectFlow.tsx` is extracted for reuse in the Channels tab inline setup.
**Tech Stack:** React 19, TypeScript, existing FastAPI endpoints
---
### Task 1: Add connector chunk counts to backend API
**Files:**
- Modify: `src/openjarvis/server/connectors_router.py`
The Channels tab needs to show chunk counts per source. Add a `chunks` field to the connector summary by querying the KnowledgeStore.
- [ ] **Step 1: Modify `_connector_summary` to include chunk count**
In `src/openjarvis/server/connectors_router.py`, find the `_connector_summary` function (line ~102). Add a chunk count from the KnowledgeStore:
```python
def _connector_summary(connector_id: str, instance: Any) -> Dict[str, Any]:
"""Build the dict returned by GET /connectors."""
chunks = 0
try:
from openjarvis.connectors.store import KnowledgeStore
store = KnowledgeStore()
rows = store._conn.execute(
"SELECT COUNT(*) FROM knowledge_chunks WHERE source = ?",
(connector_id,),
).fetchone()
chunks = rows[0] if rows else 0
except Exception:
pass
return {
"connector_id": connector_id,
"display_name": getattr(instance, "display_name", connector_id),
"auth_type": getattr(instance, "auth_type", "unknown"),
"connected": instance.is_connected(),
"chunks": chunks,
}
```
- [ ] **Step 2: Verify endpoint returns chunks**
```bash
uv run python3 -c "
import httpx
r = httpx.get('http://127.0.0.1:8222/connectors', timeout=5)
for c in r.json().get('connectors', [])[:5]:
print(f'{c[\"connector_id\"]}: connected={c[\"connected\"]}, chunks={c.get(\"chunks\", \"?\")}')
"
```
- [ ] **Step 3: Lint + commit**
```bash
uv run ruff check src/openjarvis/server/connectors_router.py
git add src/openjarvis/server/connectors_router.py
git commit -m "feat: include chunk counts in connector list API response"
```
---
### Task 2: Rewrite Channels tab as data sources view
**Files:**
- Modify: `frontend/src/pages/AgentsPage.tsx`
- Modify: `frontend/src/lib/connectors-api.ts`
Replace the existing `ChannelsTab` (messaging channels) with a new `ChannelsTab` (data sources).
- [ ] **Step 1: Update ConnectorInfo type in connectors-api.ts to include chunks**
In `frontend/src/lib/connectors-api.ts`, find the `ConnectorInfo` type (or wherever it's defined) and add `chunks`:
If there's an interface in `types/connectors.ts`:
```typescript
export interface ConnectorInfo {
connector_id: string;
display_name: string;
auth_type: 'oauth' | 'local' | 'bridge' | 'filesystem';
connected: boolean;
auth_url?: string;
mcp_tools?: string[];
chunks?: number; // ADD THIS
}
```
- [ ] **Step 2: Replace the ChannelsTab component**
In `AgentsPage.tsx`, find the existing `AVAILABLE_CHANNELS` array and `ChannelsTab` component (around lines 1000-1170). Replace the entire block with a new `ChannelsTab` that shows data sources:
```typescript
import { SOURCE_CATALOG, ConnectorMeta, ConnectRequest } from '../types/connectors';
import { listConnectors, connectSource } from '../lib/connectors-api';
function ChannelsTab({ agentId }: { agentId: string }) {
const [connectors, setConnectors] = useState<
Array<{ connector_id: string; display_name: string; connected: boolean; chunks: number }>
>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loadConnectors = useCallback(() => {
listConnectors()
.then((list) =>
setConnectors(
list.map((c) => ({
connector_id: c.connector_id,
display_name: c.display_name,
connected: c.connected,
chunks: (c as any).chunks || 0,
})),
),
)
.catch(() => {});
}, []);
useEffect(() => { loadConnectors(); }, [loadConnectors]);
const handleConnect = async (id: string, req: ConnectRequest) => {
setLoading(true);
try {
await connectSource(id, req);
setExpandedId(null);
loadConnectors();
} catch {
// error handling
} finally {
setLoading(false);
}
};
const connected = connectors.filter((c) => c.connected);
const notConnected = connectors.filter((c) => !c.connected);
// Merge with SOURCE_CATALOG for icons/descriptions
const getMeta = (id: string) =>
SOURCE_CATALOG.find((s) => s.connector_id === id);
const iconMap: Record<string, string> = {
gmail: '✉️', gmail_imap: '✉️', slack: '#',
imessage: '💬', gdrive: '📁', notion: '📄',
obsidian: '📁', granola: '🎙️', gcalendar: '📅',
gcontacts: '📇', outlook: '✉️', apple_notes: '🍎',
dropbox: '📦', whatsapp: '📱',
};
return (
<div style={{ padding: 16 }}>
<div style={{
color: 'var(--color-text-secondary)',
fontSize: 12, marginBottom: 12,
}}>
Data sources your agent can search
</div>
{/* Connected sources grid */}
{connected.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8, marginBottom: 12,
}}>
{connected.map((c) => (
<div
key={c.connector_id}
style={{
background: 'var(--color-bg-secondary)',
border: '1px solid #2a5a3a',
borderRadius: 6, padding: '10px 12px',
display: 'flex', alignItems: 'center', gap: 8,
}}
>
<span>{iconMap[c.connector_id] || '🔗'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>
{c.display_name}
</div>
<div style={{ fontSize: 10, color: '#4ade80' }}>
{c.chunks.toLocaleString()} chunks
</div>
</div>
<span style={{ color: '#4ade80', fontSize: 12 }}></span>
</div>
))}
</div>
)}
{/* Not connected grid */}
{notConnected.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
}}>
{notConnected.map((c) => {
const meta = getMeta(c.connector_id);
const isExpanded = expandedId === c.connector_id;
return (
<div
key={c.connector_id}
style={{
background: 'var(--color-bg-secondary)',
border: '1px dashed var(--color-border)',
borderRadius: 6, overflow: 'hidden',
opacity: isExpanded ? 1 : 0.6,
gridColumn: isExpanded ? '1 / -1' : undefined,
}}
>
<div
style={{
padding: '10px 12px', display: 'flex',
alignItems: 'center', gap: 8,
cursor: 'pointer',
}}
onClick={() =>
setExpandedId(isExpanded ? null : c.connector_id)
}
>
<span>{iconMap[c.connector_id] || '🔗'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500,
color: 'var(--color-text-secondary)' }}>
{c.display_name}
</div>
<div style={{ fontSize: 10,
color: 'var(--color-text-secondary)' }}>
Not connected
</div>
</div>
<span style={{
color: '#7c3aed', fontSize: 11, fontWeight: 500,
}}>
{isExpanded ? '✕ Close' : '+ Add'}
</span>
</div>
{/* Inline setup panel */}
{isExpanded && meta?.steps && (
<div style={{
borderTop: '1px solid var(--color-border)',
padding: 12,
}}>
{meta.steps.map((step, i) => (
<div
key={i}
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 6, padding: 10,
marginBottom: 8,
}}
>
<div style={{
color: '#7c3aed', fontSize: 10,
fontWeight: 600, marginBottom: 3,
}}>
STEP {i + 1}
</div>
<div style={{
fontSize: 12, marginBottom: step.url ? 4 : 0,
}}>
{step.label}
</div>
{step.url && (
<a
href={step.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: '#60a5fa', fontSize: 11,
textDecoration: 'underline',
}}
>
{step.urlLabel || 'Open'}
</a>
)}
</div>
))}
{meta.inputFields && (
<InlineConnectForm
fields={meta.inputFields}
loading={loading}
onSubmit={(req) =>
handleConnect(c.connector_id, req)
}
/>
)}
<div style={{
fontSize: 10, color: 'var(--color-text-secondary)',
textAlign: 'center', marginTop: 8,
}}>
🔒 Read-only access · No data leaves your device
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
function InlineConnectForm({
fields,
loading,
onSubmit,
}: {
fields: Array<{ name: string; placeholder: string; type?: string }>;
loading: boolean;
onSubmit: (req: ConnectRequest) => void;
}) {
const [inputs, setInputs] = useState<Record<string, string>>({});
const update = (name: string, value: string) =>
setInputs((p) => ({ ...p, [name]: value }));
const allFilled = fields.every((f) => inputs[f.name]?.trim());
const submit = () => {
const req: ConnectRequest = {};
for (const f of fields) {
if (f.name === 'email') req.email = inputs.email;
else if (f.name === 'password') req.password = inputs.password;
else if (f.name === 'token') req.token = inputs.token;
else if (f.name === 'path') req.path = inputs.path;
}
if (req.email && req.password) {
req.token = `${req.email}:${req.password}`;
req.code = req.token;
}
if (req.token && !req.code) req.code = req.token;
onSubmit(req);
};
return (
<div>
{fields.map((f) => (
<input
key={f.name}
value={inputs[f.name] || ''}
onChange={(e) => update(f.name, e.target.value)}
placeholder={f.placeholder}
type={f.type || 'text'}
style={{
width: '100%', padding: '7px 10px',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 4, color: 'var(--color-text)',
fontSize: 12, marginBottom: 6,
boxSizing: 'border-box',
}}
/>
))}
<button
onClick={submit}
disabled={loading || !allFilled}
style={{
width: '100%', padding: 8,
background: loading || !allFilled ? '#444' : '#7c3aed',
color: 'white', border: 'none',
borderRadius: 6, fontSize: 12, cursor: 'pointer',
}}
>
{loading ? 'Connecting...' : 'Connect'}
</button>
</div>
);
}
```
- [ ] **Step 3: Update DETAIL_TABS to rename and add Messaging**
Find `DETAIL_TABS` (line ~1553). Update to:
```typescript
const DETAIL_TABS = [
{ id: 'overview', label: 'Overview', icon: Activity },
{ id: 'interact', label: 'Interact', icon: MessageSquare },
{ id: 'channels', label: 'Channels', icon: Database },
{ id: 'messaging', label: 'Messaging', icon: Wifi },
{ id: 'tasks', label: 'Tasks', icon: ListTodo },
{ id: 'memory', label: 'Memory', icon: Brain },
{ id: 'learning', label: 'Learning', icon: Settings },
{ id: 'logs', label: 'Logs', icon: FileText },
] as const;
```
Add `Database` to lucide-react imports. Update detailTab type:
```typescript
const [detailTab, setDetailTab] = useState<
'overview' | 'interact' | 'channels' | 'messaging' | 'tasks' | 'memory' | 'learning' | 'logs'
>('overview');
```
- [ ] **Step 4: Add MessagingTab component and wire both tabs**
Add the `MessagingTab` component (reuses the old `AVAILABLE_CHANNELS` data but with clear UX):
```typescript
const MESSAGING_CHANNELS = [
{
type: 'imessage', name: 'iMessage', icon: '💬',
description: 'Text from your iPhone, iPad, or Mac',
activeTemplate: (id: string) => `Text ${id} from your iPhone`,
setupLabel: 'Phone number or email for the agent to use',
placeholder: '+15551234567',
},
{
type: 'slack', name: 'Slack', icon: '#',
description: 'Message from any Slack workspace',
activeTemplate: (id: string) => `DM @jarvis in ${id}`,
setupLabel: 'Slack bot token (xoxb-...)',
placeholder: 'xoxb-...',
},
{
type: 'whatsapp', name: 'WhatsApp', icon: '📱',
description: 'Message via WhatsApp',
activeTemplate: (id: string) => `Message ${id} on WhatsApp`,
setupLabel: 'WhatsApp access token',
placeholder: 'Access token',
},
{
type: 'twilio', name: 'SMS (Twilio)', icon: '📨',
description: 'Text from any phone via Twilio',
activeTemplate: (id: string) => `Text ${id} from any phone`,
setupLabel: 'Twilio phone number',
placeholder: '+15551234567',
},
];
function MessagingTab({ agentId }: { agentId: string }) {
const [bindings, setBindings] = useState<ChannelBinding[]>([]);
const [setupType, setSetupType] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const loadBindings = useCallback(() => {
fetchAgentChannels(agentId).then(setBindings).catch(() => setBindings([]));
}, [agentId]);
useEffect(() => { loadBindings(); }, [loadBindings]);
const handleSetup = async (channelType: string) => {
if (!inputValue.trim()) return;
setLoading(true);
try {
await bindAgentChannel(agentId, channelType, {
identifier: inputValue.trim(),
});
setSetupType(null);
setInputValue('');
loadBindings();
} catch { /* */ } finally { setLoading(false); }
};
const handleRemove = async (bindingId: string) => {
try {
await unbindAgentChannel(agentId, bindingId);
loadBindings();
} catch { /* */ }
};
return (
<div style={{ padding: 16 }}>
<div style={{
color: 'var(--color-text-secondary)',
fontSize: 12, marginBottom: 12,
}}>
Talk to your agent from your phone or other platforms
</div>
{MESSAGING_CHANNELS.map((ch) => {
const binding = bindings.find((b) => b.channel_type === ch.type);
const identifier = binding?.config?.identifier as string || binding?.session_id || '';
const isSetup = setupType === ch.type;
return (
<div
key={ch.type}
style={{
background: 'var(--color-bg-secondary)',
border: binding
? '1px solid #2a5a3a'
: '1px dashed var(--color-border)',
borderRadius: 8, marginBottom: 8,
opacity: binding ? 1 : 0.6,
overflow: 'hidden',
}}
>
<div style={{
display: 'flex', alignItems: 'center',
padding: '12px 14px',
}}>
<span style={{ fontSize: 16, marginRight: 10 }}>{ch.icon}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{ch.name}</div>
<div style={{ fontSize: 11,
color: binding ? '#4ade80' : 'var(--color-text-secondary)',
}}>
{binding
? ch.activeTemplate(identifier)
: 'Not set up'}
</div>
</div>
{binding ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
background: '#2a5a3a', color: '#4ade80',
padding: '2px 8px', borderRadius: 10,
fontSize: 10,
}}>Active</span>
<button
onClick={() => handleRemove(binding.id)}
style={{
fontSize: 10, padding: '2px 8px',
background: 'transparent',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)',
borderRadius: 4, cursor: 'pointer',
}}
>Remove</button>
</div>
) : (
<button
onClick={() => {
setSetupType(isSetup ? null : ch.type);
setInputValue('');
}}
style={{
fontSize: 10, padding: '3px 12px',
background: '#7c3aed', color: 'white',
border: 'none', borderRadius: 5,
cursor: 'pointer',
}}
>
{isSetup ? 'Cancel' : 'Set Up'}
</button>
)}
</div>
{isSetup && (
<div style={{
borderTop: '1px solid var(--color-border)',
padding: '10px 14px',
background: 'var(--color-bg)',
}}>
<div style={{
fontSize: 11, marginBottom: 6,
color: 'var(--color-text-secondary)',
}}>{ch.setupLabel}</div>
<div style={{ display: 'flex', gap: 6 }}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={ch.placeholder}
style={{
flex: 1, padding: '6px 10px',
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
borderRadius: 4, color: 'var(--color-text)',
fontSize: 12,
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSetup(ch.type);
}}
/>
<button
onClick={() => handleSetup(ch.type)}
disabled={loading || !inputValue.trim()}
style={{
fontSize: 11, padding: '6px 14px',
background: '#7c3aed', color: 'white',
border: 'none', borderRadius: 4,
cursor: 'pointer',
opacity: loading || !inputValue.trim() ? 0.5 : 1,
}}
>
{loading ? '...' : 'Connect'}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
```
Wire both tabs in the content rendering section:
```typescript
{detailTab === 'channels' && (
<ChannelsTab agentId={selectedAgent.id} />
)}
{detailTab === 'messaging' && (
<MessagingTab agentId={selectedAgent.id} />
)}
```
- [ ] **Step 5: Add required imports**
At the top of `AgentsPage.tsx`:
```typescript
import { Database } from 'lucide-react';
import { SOURCE_CATALOG } from '../types/connectors';
import type { ConnectRequest } from '../types/connectors';
import { listConnectors, connectSource } from '../lib/connectors-api';
```
- [ ] **Step 6: TypeScript check + build**
```bash
cd frontend && npx tsc --noEmit 2>&1 | head -20
cd frontend && npm run build 2>&1 | tail -10
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/pages/AgentsPage.tsx frontend/src/lib/connectors-api.ts frontend/src/types/connectors.ts
git commit -m "feat: replace Channels tab with data sources + add Messaging tab"
```
---
### Task 3: E2E test both tabs
**Files:** None (manual testing)
- [ ] **Step 1: Restart server to pick up new static files**
```bash
# Kill existing server, rebuild frontend, restart
kill $(pgrep -f "jarvis serve") 2>/dev/null
cd frontend && npm run build
uv run jarvis serve --port 8222 --model qwen3.5:9b &
```
- [ ] **Step 2: Test Channels tab**
1. Open http://127.0.0.1:8222 → Agents → open agent
2. Click "Channels" tab
3. Verify connected sources show with green border + chunk counts
4. Verify unconnected sources show with dashed border + "+ Add"
5. Click "+ Add" on an unconnected source → verify step-by-step instructions appear inline
- [ ] **Step 3: Test Messaging tab**
1. Click "Messaging" tab
2. Verify active channels show with "Text +1... from your iPhone" instructions
3. Verify inactive channels show "Set Up" button
4. Click "Set Up" → verify input field appears with clear label
- [ ] **Step 4: Push**
```bash
git push origin feat/deep-research-setup
```
File diff suppressed because it is too large Load Diff
@@ -1,356 +0,0 @@
# Personal DeepResearch Template + Server Wiring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Register "Personal Deep Research" as a default agent template, wire DeepResearch tools in the server streaming endpoint, and add a `jarvis research` CLI alias — so users can create and chat with the agent from the desktop app, browser, or CLI.
**Architecture:** A new TOML template file auto-discovered by `AgentManager.list_templates()`. The server's `_stream_managed_agent()` is extended to build the 4 DeepResearch tools (knowledge_search, knowledge_sql, scan_chunks, think) from `~/.openjarvis/knowledge.db` when `agent_type == "deep_research"`. A CLI alias `jarvis research` wraps the existing `deep-research-setup` command.
**Tech Stack:** Python 3.10+, TOML, FastAPI, Click, pytest
---
### Task 1: Create the agent template TOML
**Files:**
- Create: `src/openjarvis/agents/templates/personal_deep_research.toml`
- [ ] **Step 1: Create the template file**
Create `src/openjarvis/agents/templates/personal_deep_research.toml`:
```toml
[template]
id = "personal_deep_research"
name = "Personal Deep Research"
description = "Search across your emails, messages, meeting notes, and documents with multi-hop retrieval and cited reports. Uses BM25 keyword search, SQL aggregation, and LM-powered semantic scanning."
agent_type = "deep_research"
schedule_type = "manual"
tools = ["knowledge_search", "knowledge_sql", "scan_chunks", "think"]
max_turns = 8
temperature = 0.3
max_tokens = 4096
```
- [ ] **Step 2: Verify template is discovered**
```bash
uv run python3 -c "
from openjarvis.agents.manager import AgentManager
templates = AgentManager.list_templates()
ids = [t['id'] for t in templates]
print(f'Templates: {ids}')
assert 'personal_deep_research' in ids, 'Template not found!'
t = next(t for t in templates if t['id'] == 'personal_deep_research')
print(f'Name: {t[\"name\"]}')
print(f'Agent type: {t[\"agent_type\"]}')
print(f'Tools: {t.get(\"tools\", [])}')
print('PASS')
"
```
Expected: Template found with correct fields.
- [ ] **Step 3: Commit**
```bash
git add src/openjarvis/agents/templates/personal_deep_research.toml
git commit -m "feat: add Personal Deep Research agent template"
```
---
### Task 2: Wire DeepResearch tools in server streaming endpoint
**Files:**
- Modify: `src/openjarvis/server/agent_manager_routes.py` (lines 263-266)
- Create: `tests/server/test_deep_research_tools_wiring.py`
- [ ] **Step 1: Write the test**
Create `tests/server/test_deep_research_tools_wiring.py`:
```python
"""Test that _stream_managed_agent wires DeepResearch tools correctly."""
from __future__ import annotations
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
def test_deep_research_agent_gets_tools(tmp_path: Path) -> None:
"""When agent_type is deep_research, agent receives 4 tools."""
from openjarvis.connectors.store import KnowledgeStore
# Create a knowledge store with some data
db_path = tmp_path / "knowledge.db"
store = KnowledgeStore(str(db_path))
store.store("test content", source="test", doc_type="note")
from openjarvis.server.agent_manager_routes import _build_deep_research_tools
tools = _build_deep_research_tools(
engine=MagicMock(),
model="test-model",
knowledge_db_path=str(db_path),
)
tool_ids = [t.tool_id for t in tools]
assert "knowledge_search" in tool_ids
assert "knowledge_sql" in tool_ids
assert "scan_chunks" in tool_ids
assert "think" in tool_ids
assert len(tools) == 4
store.close()
def test_deep_research_tools_returns_empty_when_no_db() -> None:
"""When knowledge.db doesn't exist, returns empty list."""
from openjarvis.server.agent_manager_routes import _build_deep_research_tools
tools = _build_deep_research_tools(
engine=MagicMock(),
model="test-model",
knowledge_db_path="/nonexistent/path/knowledge.db",
)
assert tools == []
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/server/test_deep_research_tools_wiring.py -v --tb=short
```
Expected: FAIL — `ImportError: cannot import name '_build_deep_research_tools'`
- [ ] **Step 3: Add `_build_deep_research_tools` function to agent_manager_routes.py**
Add this function near the top of the file (after the imports, before the router definitions):
```python
def _build_deep_research_tools(
engine: Any,
model: str,
knowledge_db_path: str = "",
) -> list:
"""Build the 4 DeepResearch tools from a KnowledgeStore.
Returns an empty list if the knowledge DB does not exist.
"""
from pathlib import Path
if not knowledge_db_path:
from openjarvis.core.config import DEFAULT_CONFIG_DIR
knowledge_db_path = str(DEFAULT_CONFIG_DIR / "knowledge.db")
if not Path(knowledge_db_path).exists():
return []
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
from openjarvis.tools.scan_chunks import ScanChunksTool
from openjarvis.tools.think import ThinkTool
store = KnowledgeStore(knowledge_db_path)
retriever = TwoStageRetriever(store)
return [
KnowledgeSearchTool(retriever=retriever),
KnowledgeSQLTool(store=store),
ScanChunksTool(store=store, engine=engine, model=model),
ThinkTool(),
]
```
- [ ] **Step 4: Wire the tools into `_stream_managed_agent`**
In `_stream_managed_agent()`, add tool wiring between the config extraction (line ~263) and the agent instantiation (line ~265). Insert after `if config.get("max_turns") is not None:` block:
```python
# Build DeepResearch tools when applicable
if agent_type == "deep_research":
tools = _build_deep_research_tools(engine=engine, model=model)
if tools:
agent_kwargs["tools"] = tools
```
- [ ] **Step 5: Ensure tests/server/__init__.py exists**
```bash
touch tests/server/__init__.py
```
- [ ] **Step 6: Run tests**
```bash
uv run pytest tests/server/test_deep_research_tools_wiring.py -v --tb=short
```
Expected: 2/2 PASS.
- [ ] **Step 7: Commit**
```bash
git add src/openjarvis/server/agent_manager_routes.py tests/server/__init__.py tests/server/test_deep_research_tools_wiring.py
git commit -m "feat: wire DeepResearch tools in managed agent streaming endpoint"
```
---
### Task 3: Add `jarvis research` CLI alias
**Files:**
- Modify: `src/openjarvis/cli/__init__.py`
- [ ] **Step 1: Add the alias**
In `src/openjarvis/cli/__init__.py`, find the line that registers `deep-research-setup` (should look like `cli.add_command(deep_research_setup, "deep-research-setup")`). Add the alias right after:
```python
cli.add_command(deep_research_setup, "research")
```
This registers the same command under the shorter name `research`.
- [ ] **Step 2: Verify**
```bash
uv run jarvis research --help | head -5
```
Expected: Shows the same help as `jarvis deep-research-setup --help`.
- [ ] **Step 3: Commit**
```bash
git add src/openjarvis/cli/__init__.py
git commit -m "feat: add 'jarvis research' CLI alias for deep-research-setup"
```
---
### Task 4: End-to-end test — template in Agents tab via API
**Files:** None (manual testing via API)
- [ ] **Step 1: Start the server**
```bash
uv run jarvis serve --host 127.0.0.1 --port 8000 &
sleep 3
```
- [ ] **Step 2: Verify template appears**
```bash
curl -s http://localhost:8000/v1/templates | python3 -c "
import sys, json
data = json.load(sys.stdin)
ids = [t['id'] for t in data['templates']]
print(f'Templates: {ids}')
assert 'personal_deep_research' in ids
print('PASS: template found')
"
```
- [ ] **Step 3: Create agent from template**
```bash
curl -s -X POST http://localhost:8000/v1/managed-agents \
-H 'Content-Type: application/json' \
-d '{"name": "My Research Agent", "template_id": "personal_deep_research"}' \
| python3 -c "
import sys, json
agent = json.load(sys.stdin)
print(f'Agent created: {agent[\"id\"]}')
print(f'Type: {agent[\"agent_type\"]}')
print(f'Status: {agent[\"status\"]}')
print(f'Config tools: {agent.get(\"config\", {}).get(\"tools\", [])}')
"
```
Expected: Agent created with `agent_type: deep_research`.
- [ ] **Step 4: Send a message and verify streaming**
```bash
AGENT_ID=$(curl -s http://localhost:8000/v1/managed-agents | python3 -c "
import sys, json
agents = json.load(sys.stdin)['agents']
dr = [a for a in agents if a['agent_type'] == 'deep_research']
print(dr[0]['id'] if dr else '')
")
curl -s -X POST "http://localhost:8000/v1/managed-agents/${AGENT_ID}/messages" \
-H 'Content-Type: application/json' \
-d '{"content": "Who are the 5 people I message the most?", "stream": true}' \
--no-buffer | head -20
```
Expected: SSE chunks streaming with agent response including tool call results.
- [ ] **Step 5: Stop server and push**
```bash
kill %1
git push origin feat/deep-research-setup
```
---
### Task 5: Update PR #78 title and description
**Files:** None (GitHub CLI)
- [ ] **Step 1: Update PR title and body**
```bash
gh pr edit 78 --repo open-jarvis/OpenJarvis \
--title "feat: Personal Deep Research — connectors, retrieval, agent, channels, desktop" \
--body "$(cat <<'PREOF'
## Summary
Full Deep Research experience: connect personal data sources → ingest → multi-hop retrieval → cited research reports via desktop app, browser, CLI, or iMessage.
### What's built
- **13 connectors**: Gmail IMAP, Outlook, Obsidian, Apple Notes, iMessage, Slack, Notion, Granola, Google Drive*, Calendar*, Contacts*, Dropbox*, WhatsApp*
- **Ingestion pipeline**: SemanticChunker, KnowledgeStore (FTS5/BM25), incremental sync, attachment store
- **4 agent tools**: knowledge_search (BM25), knowledge_sql (SQL aggregation), scan_chunks (LM semantic grep), think (reasoning)
- **DeepResearchAgent**: multi-hop with query expansion, forced synthesis, cited reports
- **Agent template**: "Personal Deep Research" in Agents tab — create and chat via desktop/browser
- **CLI**: `jarvis deep-research-setup` / `jarvis research` — auto-detect sources, ingest, chat
- **API**: 6 connector endpoints + managed agent streaming
- **Channel gateway**: ChannelBridge, webhook routes for Twilio/BlueBubbles/WhatsApp
- **220+ tests**
*\* = mocked tests only*
### Live-verified with real data
- 55,302 chunks (Apple Notes + iMessage + Gmail + Slack + Notion + Granola)
- DeepResearchAgent with Qwen3.5 35B MoE producing cited reports
- Cross-source queries: found Spain trip dates from iMessages, ranked top contacts via SQL, identified recurring meetings from Granola
## Test plan
- [ ] `uv run pytest tests/ -v` — all tests pass
- [ ] `uv run jarvis research` — end-to-end CLI flow
- [ ] Create agent from template in Agents tab
- [ ] Stream chat response with tool calls
- [ ] Verify read-only: no writes to any connector
🤖 Generated with [Claude Code](https://claude.com/claude-code)
PREOF
)"
```
- [ ] **Step 2: Push our branch to the PR branch**
```bash
git push origin feat/deep-research-setup:feat/mobile-channel-gateway --force-with-lease
```
@@ -1,468 +0,0 @@
# Deep Research Setup Experience — Design Spec
**Date:** 2026-03-25
**Status:** Draft
**Scope:** End-to-end design for the OpenJarvis "killer install + setup" experience — from first launch to Deep Research over your personal data, accessible via desktop app, CLI, and messaging channels.
---
## 1. Vision
An Apple-like onboarding experience that connects users to their personal data (email, messaging, documents, calendar, contacts, notes), indexes it privately on-device, and provides a Deep Research agent that can navigate the full corpus with multi-hop retrieval — all accessible from the desktop app, CLI, or messaging channels like iMessage, WhatsApp, and Slack.
## 2. Architecture Overview
Seven layers from data sources to user interfaces:
```
Data Sources (12 services)
↓ OAuth / API / Local DB access
Connector Layer (native bulk sync + MCP real-time tools)
↓ Normalized Document objects
Ingestion Pipeline (normalize → deduplicate → chunk → index)
On-Device Storage (SQLite/FTS5 + ColBERTv2 + attachment store)
Multi-Layer Retrieval (BM25 recall → ColBERTv2 rerank → agent multi-hop)
Deep Research Agent (ToolUsingAgent with knowledge_search, MCP tools, think, web_search)
User Interfaces (Desktop App, CLI, iMessage, WhatsApp, Slack)
```
### Design Principles
- **Local-first:** All data stored on-device in SQLite. No cloud dependency for storage or retrieval.
- **Hybrid connectors:** Native bulk sync for initial ingest, MCP tools for real-time agent queries.
- **Two-stage retrieval:** BM25 for fast recall, ColBERTv2 for precise reranking. Agent adds multi-hop.
- **Progressive onboarding:** Pick sources → guided OAuth for each → bulk ingest → ready to research.
- **Escalation model:** Quick answers in chat channels, full reports in desktop app.
## 3. Data Sources
Twelve data sources across three categories:
| Category | Sources | Auth Type |
|----------|---------|-----------|
| **Communication** | Gmail, Slack, iMessage, WhatsApp | OAuth, OAuth, Local DB, Bridge (QR) |
| **Documents** | Google Drive, Dropbox, Notion | OAuth, OAuth, OAuth |
| **PIM** | Google Calendar, Google Contacts, Apple Notes, Obsidian/Markdown | OAuth, OAuth, Local DB, Filesystem |
## 4. Setup Wizard (Desktop-First)
The primary onboarding path is the Tauri desktop app. The CLI gets a simpler `jarvis connect` command that performs the same operations text-based. The desktop wizard is the "wow" experience — polished visual design with real product logos, clean progress bars, and premium feel throughout.
### Step 1: Welcome + Hardware Detection
Auto-detects CPU, RAM, GPU. Shows friendly capability messaging (e.g., "MacBook Pro M4 Max, 128GB RAM — you can run powerful local models"). Background: Ollama starts, recommended model begins downloading.
**Builds on:** Existing desktop boot sequence (Ollama → model → server) and `jarvis init` hardware detection.
**New:** Friendlier UI with hardware capability messaging, sets user expectations for what's possible on their machine.
### Step 2: Engine Setup
If Ollama is already installed, skip silently. If not, one-click install or "Use cloud instead" option. Model download with a polished progress bar. Option to add cloud API keys for hybrid local+cloud mode.
**Builds on:** Existing `jarvis init` engine selection and model download logic.
**New:** Automated Ollama install, cloud key entry UI, visual progress.
### Step 3: Pick Your Sources
A grid of source cards with product logos, grouped by category (Communication / Documents / PIM). User toggles on/off. "Connect what you want now — you can always add more later."
**Entirely new.** This step and everything after it is new functionality.
### Step 4: Connect Each Source (Guided)
For each selected source, one at a time, with a sidebar checklist showing connected/remaining status. "Skip" option for each. Four connection patterns:
**OAuth services** (Gmail, Drive, Slack, Calendar, Contacts, Dropbox, Notion): Show what permissions are requested and why → "Open in browser" button → OAuth consent screen → redirect back → green checkmark. OpenJarvis registers its own OAuth apps — users never create developer credentials.
**Local access** (iMessage, Apple Notes): Request macOS Full Disk Access permission → explain why in plain language → link to System Preferences → verify access was granted.
**Bridge services** (WhatsApp): Display QR code in the app → user scans with their phone → connection confirmed.
**Local files** (Obsidian/Markdown): File picker → select vault directory → confirm.
### Step 5: Ingest & Index
Bulk sync begins for all connected sources in parallel. Live dashboard shows per-source progress with item counts and progress bars:
```
✉ Gmail 12,847 / ~45,000 emails ████████░░░░░░░ 28%
💬 Slack 3,201 / ~8,500 messages ██████████░░░░░ 38%
📁 Google Drive 156 / ~420 docs █████████░░░░░░ 37%
📱 iMessage ✓ Complete 24,102 messages indexed
```
**Key UX:** User doesn't have to wait. They can start chatting with partial data — "Your data is still syncing, but you can start asking questions now." Ingest is resumable: close the app, come back later, it picks up where it left off.
### Step 6: Ready — First Query
Drops user into the Deep Research chat. Suggested starter queries based on their connected sources:
- "What were the key decisions from last week's team threads?"
- "Find the proposal doc Sarah shared about the Q3 roadmap"
- "Summarize my unread emails from today"
## 5. Connector Layer
### New Abstraction: `BaseConnector`
Lives in `src/openjarvis/connectors/` — separate from channels. Channels are for real-time communication (send/receive). Connectors are for data ingest (bulk download + index history).
```python
class BaseConnector(ABC):
# Identity
connector_id: str # "gmail", "slack", etc.
display_name: str # "Gmail", "Slack", etc.
icon_url: str # Product logo for UI
auth_type: str # "oauth" | "local" | "bridge" | "filesystem"
# Auth lifecycle
def auth_url() -> str # OAuth: generate consent URL
def handle_callback(code: str) -> None # OAuth: exchange code for tokens
def is_connected() -> bool
def disconnect() -> None
# Bulk sync (native)
def sync(since: datetime | None) -> Iterator[Document]
def sync_status() -> SyncStatus
# MCP real-time (agent-callable)
def mcp_tools() -> list[ToolSpec]
```
Registered via `@ConnectorRegistry.register("gmail")` — new registry alongside the existing ones.
### SyncStatus
```python
@dataclass
class SyncStatus:
state: str # "idle", "syncing", "paused", "error"
items_synced: int # items completed so far
items_total: int # estimated total (0 if unknown)
last_sync: datetime | None
cursor: str | None # opaque checkpoint for resume
error: str | None # last error message if state == "error"
```
### Universal Document Schema
All connectors normalize their data into a common dataclass:
```python
@dataclass
class Document:
doc_id: str # globally unique (connector:source_id)
source: str # "gmail", "slack", "gdrive", etc.
doc_type: str # "email", "message", "document", "event", "contact", "note"
content: str # plain text body
title: str # subject line, filename, event title
author: str # sender / creator
participants: list[str] # recipients, channel members, attendees
timestamp: datetime # when created/sent
thread_id: str | None # conversation grouping
url: str | None # deep link back to original source
attachments: list[Attachment]
metadata: dict # source-specific (labels, channel name, folder, etc.)
```
Six `doc_type` values: `email`, `message`, `document`, `event`, `contact`, `note`.
### Per-Connector Details
**Gmail** — Gmail API (REST). Scope: `gmail.readonly`. Bulk sync via `messages.list``messages.get`, paginated, resumable via `historyId`. MCP tools: `gmail_search_emails`, `gmail_get_thread`, `gmail_list_unread`.
**Slack** — Slack Web API. Scopes: `channels:history`, `channels:read`, `users:read`. Bulk sync via `conversations.history` per channel, cursor-paginated. MCP tools: `slack_search_messages`, `slack_get_thread`, `slack_list_channels`.
**iMessage** — Direct SQLite read of `~/Library/Messages/chat.db`. Requires macOS Full Disk Access. Bulk sync queries message + handle tables. MCP tools: `imessage_search_messages`, `imessage_get_conversation`.
**WhatsApp** — Baileys Node.js bridge (existing). Auth via QR code scan. Bulk sync via `fetchMessageHistory` through Baileys protocol. MCP tools: `whatsapp_search_messages`, `whatsapp_get_chat`.
**Google Drive** — Drive API v3. Scope: `drive.readonly`. Bulk sync via `files.list` → export/download. Google Docs/Sheets/Slides exported as text/markdown. MCP tools: `gdrive_search_files`, `gdrive_get_document`, `gdrive_list_recent`.
**Dropbox** — Dropbox API v2. Scopes: `files.metadata.read`, `files.content.read`. Bulk sync via `list_folder` recursive → download text-extractable files. MCP tools: `dropbox_search_files`, `dropbox_get_file`, `dropbox_list_recent`.
**Notion** — Notion API. Read content integration scope. Bulk sync via `search` → retrieve blocks → render to markdown. MCP tools: `notion_search_pages`, `notion_get_page`.
**Google Calendar** — Calendar API v3. Scope: `calendar.readonly`. Bulk sync via `events.list` across calendars, paginated. MCP tools: `calendar_get_events_today`, `calendar_search_events`, `calendar_next_meeting`.
**Google Contacts** — People API. Scope: `contacts.readonly`. Bulk sync via `people.connections.list`, paginated. MCP tools: `contacts_find`, `contacts_get_info`.
**Apple Notes** — Direct SQLite read of `~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite`. Requires Full Disk Access. Bulk sync decodes `ZICNOTEDATA` (gzipped HTML → plain text). MCP tools: `notes_search`, `notes_get_note`.
**Obsidian/Markdown** — Direct filesystem read of a user-selected vault directory. Bulk sync walks `.md` files, parses YAML frontmatter + wikilinks. MCP tools: `obsidian_search_notes`, `obsidian_get_note`, `obsidian_list_recent`.
### SyncEngine
A shared `SyncEngine` orchestrates all connectors:
- **Checkpoint/Resume:** Stores last sync cursor per connector in SQLite. Interrupted syncs resume from the checkpoint.
- **Rate Limiting:** Per-connector rate limits with exponential backoff. Respects API quotas (e.g., Gmail: 250 quota units/sec).
- **Incremental Sync:** After initial bulk sync, periodic delta sync fetches only new/modified items. Configurable interval.
- **Parallel Ingest:** Multiple connectors sync concurrently. Documents stream into the ingestion pipeline as they arrive.
## 6. Ingestion Pipeline
Four stages from raw documents to retrieval-ready index:
### Stage 1: Normalize
Connectors yield `Document` objects. HTML emails converted to plain text via `html2text`. Google Docs exported as markdown. Notion blocks rendered to markdown. Apple Notes decompressed from gzipped HTML to plain text. Attachments extracted, hashed (SHA-256), stored in content-addressed blob store.
### Stage 2: Deduplicate
Cross-source dedup by `doc_id` (connector:source_id). If the same message appears in a Slack export and an email notification, keep both but link them via metadata. Attachments deduped by SHA-256 — a file shared across Gmail, Slack, and Drive is stored once.
### Stage 3: Chunk
Uses the existing `chunking.py` infrastructure with type-aware strategies. **Chunk size defaults to the active reranker's max context:** 512 tokens when ColBERTv2 is available (its WordPiece max), 256 tokens when falling back to FAISS with `all-MiniLM-L6-v2`.
**Semantic splitting rules (never split mid-sentence):**
- **Emails/Messages:** Keep whole if under the chunk limit. For long threads, split on message/reply boundaries — never mid-message. If a single message exceeds the limit, fall back to sentence-boundary splitting.
- **Documents:** Split on section boundaries (`## Heading`) first, then paragraph boundaries within sections. If a single paragraph exceeds the limit, split on sentence boundaries. Each chunk inherits its section heading as metadata.
- **Events:** Single chunk per event (title + attendees + description + location). No splitting needed.
- **Contacts:** Single chunk per contact (name + email + phone + org + notes). Structured for entity lookup.
- **Notes:** Same rules as documents — section boundaries first, then paragraphs, then sentences.
**Metadata carries context:** Each chunk inherits the parent document's title, author, timestamp, source, and its section heading (if any), so retrieval results are never orphaned fragments with no context about where they came from.
### Stage 4: Index (Dual-Write)
Each chunk is written to both indexes simultaneously:
**SQLite + FTS5** — durable store with BM25 ranking. Rust-backed (existing infrastructure). Stores: chunk content, doc_id, source, doc_type, title, author, participants, timestamp, thread_id, url, metadata JSON, chunk_index.
**ColBERTv2 Index** — token-level embeddings for semantic reranking. **New: persisted to disk** (current implementation is in-memory). Memory-mapped tensor file with chunk_id→offset index. Append-only for incremental indexing. Lazy loading — only map pages touched by current query.
### SQLite Schema
Extends the existing `documents` table with source-aware columns:
```sql
CREATE TABLE documents (
id TEXT PRIMARY KEY, -- chunk_id
doc_id TEXT NOT NULL, -- parent document (connector:source_id)
content TEXT NOT NULL, -- chunk text
source TEXT NOT NULL, -- "gmail", "slack", "gdrive", ...
doc_type TEXT NOT NULL, -- "email", "message", "document", ...
title TEXT, -- subject / filename / event title
author TEXT, -- sender / creator
participants TEXT, -- JSON array
timestamp TEXT NOT NULL, -- ISO 8601
thread_id TEXT, -- conversation grouping
url TEXT, -- deep link to original
metadata TEXT, -- JSON (labels, channel, folder, etc.)
chunk_index INTEGER, -- position within parent doc
created_at TEXT NOT NULL -- when indexed
);
CREATE VIRTUAL TABLE documents_fts USING fts5(
content, title, author,
content=documents, content_rowid=rowid,
tokenize='porter unicode61'
);
CREATE INDEX idx_source ON documents(source);
CREATE INDEX idx_doc_type ON documents(doc_type);
CREATE INDEX idx_author ON documents(author);
CREATE INDEX idx_timestamp ON documents(timestamp);
CREATE INDEX idx_thread ON documents(thread_id);
CREATE INDEX idx_doc_id ON documents(doc_id);
```
### ColBERTv2 Persistence (New)
The current ColBERT backend is in-memory only. For a personal knowledge base with 100K+ chunks, disk persistence is required.
- **On-disk format:** Memory-mapped tensor file + chunk_id→offset index. Lazy loading. Append-only for incremental indexing.
- **Fallback:** Systems without ColBERT dependencies (no torch) fall back to BM25 + FAISS (single-vector with `all-MiniLM-L6-v2`). The retrieval API is identical — callers don't know which reranker is active. When falling back to FAISS, chunk size adjusts to 256 tokens to match the embedding model's context.
### Attachment Store
Content-addressed blob store at `~/.openjarvis/blobs/{sha256[:2]}/{sha256}`. A metadata table tracks: hash, filename, mime_type, size_bytes, and source doc_ids.
Text extraction: PDFs via pdfplumber (existing). Office docs via python-docx/openpyxl. Images skipped for now (future: OCR). Extracted text gets chunked and indexed alongside the parent document.
## 7. Multi-Layer Retrieval
Two-stage retrieval as the base primitive, with agent-level multi-hop on top:
### Stage 1: BM25 Recall
FTS5 MATCH query with Porter stemming against the `documents_fts` virtual table. Returns top-100 candidates ranked by BM25. Supports pre-filtering by source, doc_type, author, and timestamp range via SQL WHERE clauses before the FTS5 MATCH.
### Stage 2: ColBERTv2 Rerank
The top-100 BM25 candidates are reranked using ColBERTv2 late interaction scoring (MaxSim). Returns top-K results (default K=10). This is where semantic understanding kicks in — ColBERT's token-level matching catches results that keyword search alone would miss or misrank.
### Stage 3: Agent Multi-Hop
The Deep Research agent can invoke the retrieval primitive (stages 1+2) multiple times across turns, refining its subqueries based on what it's found. Configurable max turns (default: 5 hops). The multi-hop logic lives at the agent level, not the retrieval level — clean separation of concerns.
## 8. Deep Research Agent
### Agent Configuration
```python
@AgentRegistry.register("deep_research")
class DeepResearchAgent(ToolUsingAgent):
agent_id = "deep_research"
# Default model: largest Qwen3.5 that fits locally, or cloud model if configured
# Default max_turns: 5 (multi-hop research turns)
```
### Tool Belt
**`knowledge_search`** (core retrieval tool): BM25→ColBERT with filters: `query`, `source`, `doc_type`, `author`, `since`/`until`, `top_k`. Returns chunks with content, source, author, timestamp, url (deep link), relevance score.
**MCP connector tools** (real-time data access): Each connected source registers MCP tools. Agent can call these for fresh data not yet in the index — `gmail_search_emails`, `gmail_get_thread`, `slack_search_messages`, `gdrive_get_document`, `calendar_get_events`, `contacts_find`, etc.
**`think`** (reasoning scratchpad): Existing tool. The agent uses this between retrieval hops to synthesize findings and plan the next subquery. Critical for multi-hop research.
**`web_search`** (external supplement): Existing tool (DuckDuckGo or Tavily). When personal data references external context (a news article, a product, a company), the agent supplements with public web search.
### Multi-Hop Research Loop
Example for "What was the context around the Kubernetes migration decision?":
```
Turn 1: think → "I need to find discussions about K8s migration across email and Slack"
knowledge_search(query="Kubernetes migration", top_k=10) → 8 results
Turn 2: think → "Found a Slack thread from March. Sarah and Mike debated cost. Get full thread + related docs"
knowledge_search(query="Kubernetes cost analysis", author="sarah", doc_type="document") → 3 results
slack_get_thread(thread_ts="1709234567.123") → full thread
Turn 3: think → "Sarah's doc references a meeting on March 5th. Check calendar + find the proposal"
calendar_get_events(query="Kubernetes", since="2024-03-01") → 1 event
knowledge_search(query="Kubernetes proposal", doc_type="document") → 2 results
Synthesize: Comprehensive report with cross-platform citations + deep links
```
### Output Format
Structured research report with:
- Narrative synthesis of findings
- Inline citations as clickable deep links back to the original source (Slack thread, Gmail message, Drive doc, Calendar event)
- Source list at the bottom with platform, title, author, and date
### Conversational Follow-up
After the initial report, the user can drill deeper or pivot:
- "Show me just Mike's cost concerns in detail" → new retrieval, focused summary
- "Draft a summary of this for my manager" → uses conversation context, no new retrieval needed
- "What happened after the decision?" → new time-scoped retrieval
## 9. Channel Plugins
### ChannelAgent
A lightweight layer between existing channel transports and the Deep Research Agent. Classifies incoming queries automatically and routes them — the user never specifies "quick" or "deep."
**Classification is automatic** based on query analysis:
- **Quick signals:** Single entity lookup, starts with "when"/"where"/"find", short query (<20 words), mentions a specific person/file/event.
- **Deep signals:** "summarize", "research", "what was the context", time ranges ("last month"), multi-entity queries.
- **Adaptive:** If the agent starts down the quick path but realizes mid-retrieval it needs more hops, it escalates on the fly.
### Quick Path (Answer Inline)
Simple lookups answered in a single retrieval hop that fit in a chat message:
```
User: When's my next meeting with Sarah?
Jarvis: Tomorrow at 2pm — "Q3 Planning Sync" with Sarah Chen, Mike Ross, and you.
Google Meet link: meet.google.com/abc-defg-hij
```
### Deep Path (Escalate to Desktop)
Complex queries requiring multi-hop retrieval or long-form output:
```
User: What was the full context behind the decision to switch to Kubernetes?
Jarvis: That's a complex question spanning multiple sources — I found relevant threads
in Slack, emails from Sarah and Mike, and a proposal doc in Drive.
I'm preparing a full research report for you.
📄 Open full report in OpenJarvis → openjarvis://research/{session_id}
```
Escalation flow:
1. Agent kicks off multi-hop research in the background (doesn't block the chat)
2. Sends brief preview + escalation link in the chat channel
3. When report is ready, sends follow-up notification: "Your report is ready"
4. Deep link (`openjarvis://research/{session_id}`) opens desktop app directly to the research session — Tauri registers as URL handler
### Per-Channel Implementation
**iMessage:** BlueBubbles bridge. Currently send-only — needs incoming message support added. Plain text + URL only. Escalation via `openjarvis://` deep link.
**WhatsApp:** Baileys bridge (existing, already bidirectional). Supports WhatsApp markdown (*bold*, _italic_). Escalation via deep link (WhatsApp renders URLs as tappable).
**Slack:** Socket Mode (existing, already bidirectional). Can use Block Kit for richer formatting — bullet lists, bold, links, buttons. DM the Jarvis bot or @mention in a channel.
### Shared Auth
For WhatsApp and Slack, the data connector and channel plugin share the same authentication. Connecting Slack as a data source automatically enables the Slack channel plugin. One OAuth flow, two capabilities. Channel plugins are configured from a "Connected Apps" settings page after initial setup.
## 10. CLI: `jarvis connect`
The CLI equivalent of the desktop setup wizard. Simpler, text-based, but same underlying logic:
```bash
jarvis connect # Interactive: pick sources, walk through auth
jarvis connect gmail # Connect a specific source
jarvis connect --list # Show connected sources and sync status
jarvis connect --sync # Trigger incremental sync for all sources
jarvis connect --disconnect gmail # Disconnect a source
```
OAuth flows open the system browser for consent, then listen on a localhost callback URL. Local access (iMessage, Apple Notes) checks permissions and provides instructions. Filesystem sources (Obsidian) accept a path argument.
## 11. Implementation Phases
### Phase 1: Connector Foundation + Gmail + Obsidian
- `BaseConnector` ABC and `ConnectorRegistry`
- `Document` schema and `SyncEngine` (checkpoint/resume, rate limiting)
- Gmail connector (OAuth flow + bulk sync + MCP tools)
- Obsidian/Markdown connector (filesystem, simplest possible)
- Extended SQLite schema with source-aware columns
- `knowledge_search` tool with filtered BM25 retrieval
- CLI `jarvis connect` command
- Tests for all of the above
### Phase 2: Desktop Wizard + More Connectors
- Desktop setup wizard UI (Steps 1-6) with product logos, progress bars, polished design
- Slack connector
- Google Drive connector
- Google Calendar + Contacts connectors
- iMessage connector (macOS local DB access)
- Parallel ingest dashboard in desktop UI
### Phase 3: ColBERTv2 Persistence + Deep Research Agent
- ColBERTv2 on-disk persistence (memory-mapped tensors)
- Two-stage retrieval (BM25 recall → ColBERT rerank)
- `DeepResearchAgent` with multi-hop loop
- Research report output with inline citations and deep links
- Conversational follow-up support
- Desktop app research UI (report rendering, source panel)
### Phase 4: Channel Plugins + Remaining Connectors
- `ChannelAgent` with automatic quick/deep classification
- iMessage channel plugin (add incoming message support to BlueBubbles)
- WhatsApp channel plugin (leverage existing Baileys bridge)
- Slack channel plugin (leverage existing Socket Mode)
- `openjarvis://` URL handler registration in Tauri
- Remaining connectors: WhatsApp, Dropbox, Notion, Apple Notes
### Phase 5: Polish + Incremental Sync
- Incremental delta sync for all connectors
- Attachment store with content-addressed dedup and text extraction
- Desktop "Connected Apps" settings page (manage sources post-setup)
- Sync status dashboard (last sync time, item counts, errors)
- Error handling and retry UX across all connectors
@@ -1,117 +0,0 @@
# Deep Research Agent v2 — Better Tools + Prompts
## Goal
Improve the DeepResearchAgent so it can answer aggregation queries ("who do I talk to most"), semantic/fuzzy queries ("which VCs have I spoken with"), and factual queries ("when was my trip to Spain") by adding SQL and LM-scan tools alongside better prompts.
## Evaluation Criteria
Re-run these 4 queries and compare to v1 (which returned empty or "no data found" for all 4):
1. "When was my most recent trip to Spain?"
2. "Which VCs have I spoken with since 2023?"
3. "Who are the 10 people I have spoken with the most over text?"
4. "What meetings take up most of my time based on my calendar and meeting logs?"
Success = the agent produces substantive, cited answers for at least 3 of 4.
## Changes
### 1. New tool: `knowledge_sql`
Read-only SQL queries against the `knowledge_chunks` table. The agent writes SELECT statements to aggregate, count, filter, and rank data.
```python
class KnowledgeSQLTool(BaseTool):
tool_id = "knowledge_sql"
def execute(self, query: str) -> ToolResult:
# Reject non-SELECT queries
# Execute against KnowledgeStore's SQLite connection
# Return formatted rows (max 50 rows)
```
Exposed schema (included in the tool description so the model knows the columns):
```
knowledge_chunks(id, content, source, doc_type, doc_id, title, author,
participants, timestamp, thread_id, url, metadata, chunk_index)
```
### 2. New tool: `scan_chunks`
Semantic grep — pulls chunks by filter, batches through the LM with a question.
```python
class ScanChunksTool(BaseTool):
tool_id = "scan_chunks"
def execute(self, question: str, source: str = "", doc_type: str = "",
since: str = "", until: str = "", max_chunks: int = 200,
batch_size: int = 20) -> ToolResult:
# 1. Pull chunks matching filters from KnowledgeStore
# 2. Batch into groups of batch_size
# 3. For each batch, call engine.generate():
# "Extract info relevant to: {question}\n\nChunks:\n{batch_text}"
# 4. Return aggregated findings
```
This is the "LM combs through tokens" capability — catches semantic matches that BM25 keyword search misses.
### 3. Wire `think` tool
The `think` tool already exists at `src/openjarvis/tools/think.py`. Wire it into the DeepResearchAgent's tool list in `deep_research_setup_cmd.py`.
### 4. System prompt rewrite
Replace the current generic prompt with one that teaches query strategies:
```
/no_think
You are a deep research agent with access to a personal knowledge base
containing emails, messages, meeting notes, documents, and notes.
## Your Tools
- **knowledge_search**: BM25 keyword search. Best for finding specific topics,
names, or phrases. Use filters (source, author, since, until) to narrow results.
- **knowledge_sql**: Run SQL queries against the knowledge_chunks table.
Schema: knowledge_chunks(id, content, source, doc_type, doc_id, title,
author, participants, timestamp, thread_id, url, metadata, chunk_index)
Best for: counting, ranking, aggregation, time-range queries.
Example: SELECT author, COUNT(*) as n FROM knowledge_chunks WHERE source='imessage' GROUP BY author ORDER BY n DESC LIMIT 10
- **scan_chunks**: Semantic search — feeds chunks to an LM that reads the actual
text looking for relevant information. Use when keyword search misses semantic
matches (e.g. searching for "VCs" when text says "fundraising round").
Slower but catches what BM25 misses.
- **think**: Reasoning scratchpad. Use between searches to plan your next query,
evaluate findings, and identify gaps.
## Strategy
1. Start with **think** to plan your approach — which tools suit this query?
2. For "who/what/how many" queries → start with **knowledge_sql**
3. For specific topics → start with **knowledge_search**
4. If keyword search returns nothing useful → try **scan_chunks** with broader filters
5. Cross-reference across sources — search emails, then messages, then notes
6. After gathering evidence → write a cited narrative report
## Citation Format
Cite sources as: [source] title -- author
Include a Sources section at the end.
```
### 5. Loop guard tuning
Increase the `max_turns` default from 5 to 8, and configure the loop guard (if present) with a higher poll budget to accommodate the larger tool set.
## What Does NOT Change
- Agent class structure (DeepResearchAgent)
- Retriever pipeline (BM25 + optional ColBERT)
- Ingestion pipeline
- Connectors
- CLI command
@@ -1,101 +0,0 @@
# Deep Research Vertical Slice — End-to-End on Laptop
## Goal
Get the full Deep Research experience working end-to-end on a MacBook with real personal data: connect local sources → ingest → retrieve → research agent → cited report. Then wire the API router so the desktop wizard UI works too.
## Scope
**In scope:**
- Fix Apple Notes protobuf text extraction + broken test
- `jarvis deep-research-setup` CLI command that auto-detects and ingests local sources
- Ingest 3 local sources into shared KnowledgeStore (~/.openjarvis/knowledge.db):
- Apple Notes (42 notes, ~100 chunks)
- iMessage (52K messages, ~52K chunks)
- Obsidian vault (size varies)
- Run TwoStageRetriever queries (BM25 + optional ColBERT rerank) against combined corpus
- Run DeepResearchAgent with Qwen3.5 4B via Ollama — multi-hop research producing cited reports
- Wire connectors_router into FastAPI app (one-line fix)
- Smoke test wizard UI via browser
**Out of scope:**
- OAuth connectors (Gmail, Drive, Calendar, Contacts, Slack, Notion, Dropbox)
- Channel plugins (iMessage/WhatsApp/Slack bot)
- Sync scheduling, performance optimization
- ColBERT disk persistence tuning
## Architecture
### Data Flow
```
Local Sources (no OAuth)
Apple Notes → NoteStore.sqlite → protobuf → plain text
iMessage → chat.db → messages with contact/conversation metadata
Obsidian → filesystem → .md files
Connectors (AppleNotesConnector, IMessageConnector, ObsidianConnector)
yield Document(doc_id, source, doc_type, content, title, author, timestamp, ...)
IngestionPipeline
Dedup by doc_id → SemanticChunker (512 token chunks) → KnowledgeStore
KnowledgeStore (~/.openjarvis/knowledge.db)
SQLite + FTS5 (BM25), ~50K-55K chunks
TwoStageRetriever
Stage 1: BM25 recall (top 100) with source/type/date filters
Stage 2: ColBERT rerank (top 10) — optional, falls back to BM25-only
DeepResearchAgent (Qwen3.5 4B via Ollama)
Multi-hop loop (up to 5 turns):
think → knowledge_search → refine → knowledge_search → ... → synthesize
Output: cited research report with source attribution
```
### CLI Command: `jarvis deep-research-setup`
Interactive CLI command that:
1. Auto-detects available local sources (checks if NoteStore.sqlite, chat.db exist; prompts for Obsidian vault path or skips if none)
2. Confirms with user which sources to connect
3. Syncs each source via SyncEngine + IngestionPipeline into ~/.openjarvis/knowledge.db
4. Prints summary (sources, document count, chunk count)
5. Drops user into `jarvis chat` with DeepResearchAgent configured
### LLM Configuration
- Engine: Ollama
- Model: qwen3.5:4b (pulled via `ollama pull qwen3.5:4b`)
- Agent: DeepResearchAgent with knowledge_search + think tools
- Expected latency: fast token generation on Apple Silicon, main cost is ColBERT model loading on first query
### API Router Fix
The `connectors_router.py` file exists with all endpoints but was never registered in the FastAPI app. Fix: add `app.include_router(create_connectors_router())` in `app.py` or `api_routes.py`.
## Test Plan
### Retrieval Quality Checks
1. Name search spanning Apple Notes + iMessage (person who appears in both)
2. Topical search across Obsidian vault
3. Time-bounded query ("what was I working on last week")
### Agent Quality Checks
1. Cross-source research query requiring multi-hop
2. Check that citations point back to real source documents
3. Check that agent uses multiple search refinements (not just one query)
### Wizard Smoke Test
1. `jarvis serve` → open localhost in browser
2. Walk through wizard: pick sources → connect → ingest → ready screen
3. Verify sync status polling shows progress
4. Verify "Ready" screen appears with suggestion queries
## Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| 4B model too small for multi-hop reasoning | Agent produces shallow one-hop reports | Swap to larger Ollama model or cloud engine |
| 52K iMessage chunks slow to ingest | Long initial setup time | Show progress, allow incremental use |
| ColBERT model loading slow on first query | Bad first-query UX | Fall back to BM25-only, lazy load ColBERT |
| Apple Notes protobuf extraction still garbled for some notes | Missing or corrupted content | Graceful degradation, skip notes with no extractable text |
@@ -1,123 +0,0 @@
# Outlook Connector + Token-Based Source Integration
## Goal
Generalize the Gmail IMAP connector to support Outlook/Microsoft 365, and extend `jarvis deep-research-setup` to detect and connect token-based sources (Slack, Notion, Granola, Gmail IMAP, Outlook) alongside the existing local sources.
## Scope
**In scope:**
- Generalize `gmail_imap.py` to accept an `imap_host` parameter (default `imap.gmail.com`)
- Create `outlook.py` as a thin subclass with `outlook.office365.com` default
- Add `detect_token_sources()` to `deep_research_setup_cmd.py` — scans `~/.openjarvis/connectors/*.json` for already-connected token-based sources
- Add interactive prompts in `deep-research-setup` to connect new token-based sources (Slack, Notion, Granola, Gmail IMAP, Outlook)
- Extend `_instantiate_connector()` to handle all connector types
- Connect and ingest all sources in one unified flow
**Out of scope:**
- Google OAuth token exchange (Drive, Calendar, Contacts still blocked)
- Microsoft Graph API
- Channel plugins (Slack bot, etc.)
## Architecture
### IMAP Generalization
`GmailIMAPConnector` gains one new parameter:
```python
class GmailIMAPConnector(BaseConnector):
_default_imap_host = "imap.gmail.com"
def __init__(self, ..., imap_host: str = "") -> None:
self._imap_host = imap_host or self._default_imap_host
```
The `sync()` method changes from `imaplib.IMAP4_SSL("imap.gmail.com")` to `imaplib.IMAP4_SSL(self._imap_host)`.
### Outlook Connector
A thin subclass in `outlook.py`:
```python
@ConnectorRegistry.register("outlook")
class OutlookConnector(GmailIMAPConnector):
connector_id = "outlook"
display_name = "Outlook / Microsoft 365"
_default_imap_host = "outlook.office365.com"
def __init__(self, email_address="", app_password="", credentials_path="", *, max_messages=500):
super().__init__(email_address, app_password,
credentials_path or str(DEFAULT_CONFIG_DIR / "connectors" / "outlook.json"),
max_messages=max_messages)
def auth_url(self) -> str:
return "https://account.microsoft.com/security"
def sync(self, *, since=None, cursor=None) -> Iterator[Document]:
# Delegate to parent but override source/doc_id prefix
for doc in super().sync(since=since, cursor=cursor):
doc.source = "outlook"
doc.doc_id = doc.doc_id.replace("gmail:", "outlook:", 1)
yield doc
```
### Token Source Detection in deep-research-setup
New function `detect_token_sources()`:
```python
_TOKEN_SOURCES = [
{"connector_id": "gmail_imap", "display_name": "Gmail (IMAP)", "creds_file": "gmail_imap.json"},
{"connector_id": "outlook", "display_name": "Outlook", "creds_file": "outlook.json"},
{"connector_id": "slack", "display_name": "Slack", "creds_file": "slack.json"},
{"connector_id": "notion", "display_name": "Notion", "creds_file": "notion.json"},
{"connector_id": "granola", "display_name": "Granola", "creds_file": "granola.json"},
]
```
For each: check if `~/.openjarvis/connectors/{creds_file}` exists with valid content. If so, include it as an already-connected source.
### Interactive Connect Flow
For sources not yet connected, `deep-research-setup` offers to connect them:
```
Detected Sources:
Apple Notes ready (local)
iMessage ready (local)
Available to Connect:
Gmail (IMAP) not connected
Outlook not connected
Slack not connected
Notion not connected
Granola not connected
Connect additional sources? [y/N]: y
Which source? [gmail_imap/outlook/slack/notion/granola]: slack
Paste your Slack token (xoxb-... or xoxe-...): xoxe-1-...
Slack: connected!
Connect another? [y/N]: y
...
```
Each connector's `handle_callback()` saves the credential. Then all connected sources are ingested together.
### Connector Credential Formats
| Connector | Credential Format | `handle_callback` Input |
|-----------|------------------|------------------------|
| Gmail IMAP | `{"email": "...", "password": "..."}` | `email:password` |
| Outlook | `{"email": "...", "password": "..."}` | `email:password` |
| Slack | `{"token": "..."}` | Raw token |
| Notion | `{"token": "..."}` | Raw token |
| Granola | `{"token": "..."}` | Raw token |
## Test Plan
- Test `OutlookConnector` inherits IMAP behavior with fake IMAP server (same pattern as gmail_imap tests)
- Test `detect_token_sources()` finds connected sources from credential files
- Test `detect_token_sources()` skips sources with missing/empty credential files
- Test interactive connect saves credentials correctly
@@ -1,85 +0,0 @@
# Channels + Messaging Tabs — Unified Agent Setup
## Goal
Replace the current disconnected setup experience with two new tabs on every agent detail page: **Channels** (data sources the agent can search) and **Messaging** (ways to talk to the agent from your phone). Clicking "+ Add" on an unconnected source opens step-by-step setup inline.
## Tab Structure
```
Overview | Interact | Channels | Messaging | Tasks | Memory | Learning | Logs
```
### Channels Tab
Shows all data source connectors with their connection status and chunk counts.
**Connected sources** — green border, checkmark, chunk count:
- Gmail (1,076 chunks) ✓
- iMessage (52,956 chunks) ✓
- Granola (1,144 chunks) ✓
- etc.
**Not connected** — dashed border, "+ Add" button:
- Google Drive — Not connected [+ Add]
- Calendar — Not connected [+ Add]
- Outlook — Not connected [+ Add]
Clicking "+ Add" expands inline with the StepByStepPanel (per-connector instructions, links to settings pages, input fields for credentials). Same flow currently in the setup wizard but embedded in the tab.
**Data shown per connected source:**
- Icon + name
- Chunk count (from KnowledgeStore)
- Connection status
- Disconnect option (on hover/click)
### Messaging Tab
Shows ways to reach the agent from your phone or other platforms.
**Active channels** — green border, clear instructions:
- iMessage: "Text +1 (408) 981-9553 from your iPhone" [Active]
- Slack: "DM @jarvis in your workspace" [Active]
**Not set up** — dashed border, "Set Up" button:
- WhatsApp — Not set up [Set Up]
- SMS (Twilio) — Not set up [Set Up]
The key UX principle: **tell the user what to do, don't ask them for config.** Active channels show the action ("Text this number"), not the plumbing ("Enter phone number to monitor").
### What Changes vs Current
| Before | After |
|--------|-------|
| Separate "Channels" tab with confusing "phone number to monitor" prompt | **Messaging** tab with clear "Text this number" instructions |
| Setup wizard only during onboarding, hidden after | **Channels** tab shows all sources with inline "+ Add" |
| No way to see connected data sources on agent page | **Channels** tab shows all with chunk counts |
| Connector setup disconnected from agent | Unified on the agent page |
### What Does NOT Change
- Overview tab (stats, config, instruction)
- Interact tab (chat)
- Tasks, Memory, Learning, Logs tabs
- Backend API endpoints (already exist)
- Connector sync logic
- iMessage daemon / ChannelBridge / webhook routes
### Backend Requirements
One new endpoint needed:
`GET /v1/connectors/status` — returns all connectors with connection status AND chunk counts from KnowledgeStore. The Channels tab calls this to populate the grid.
Everything else (bind/unbind channel, connect/disconnect source, sync status) already exists.
## Test Plan
- Channels tab shows connected sources with chunk counts
- Channels tab shows unconnected sources with "+ Add"
- Clicking "+ Add" shows step-by-step instructions inline
- Completing setup adds the source and starts ingestion
- Messaging tab shows active channels with clear action text
- Messaging tab shows "Set Up" for inactive channels
- Setting up iMessage starts the daemon
- Both tabs appear on ALL agents, not just DeepResearch
@@ -1,120 +0,0 @@
# Channels Tab + Seamless Connector Setup
## Goal
Add a Channels tab to every agent for bidirectional messaging (iMessage, Slack, WhatsApp, SMS), and build per-connector step-by-step setup guides in the wizard with one-click OAuth where available.
## Sub-project 1: Channels Tab on Every Agent
### What it does
A new "Channels" tab on the agent detail page showing messaging interfaces to reach the agent. Users can connect/disconnect channels from the UI. Appears on ALL agents.
### Frontend
Add a 7th tab "Channels" to the agent detail view in `AgentsPage.tsx`. Content:
- Header: "Talk to this agent from" + "+ Add Channel" button
- Channel list: iMessage, Slack, WhatsApp, SMS (Twilio)
- Each channel shows: icon, name, description, status badge (Active/Not connected)
- Connected channels expand to show: monitored contact/workspace, instructions, Stop/Settings buttons
- Disconnected channels show a "Connect" button
**"+ Add Channel" / "Connect" flow:**
- Opens a modal/inline form specific to the channel type:
- **iMessage:** prompt for phone number or contact to monitor → starts daemon
- **Slack:** prompt for bot token → registers webhook
- **WhatsApp:** prompt for access token + phone number ID → registers webhook
- **SMS:** prompt for Twilio account SID + auth token + phone number → registers webhook
### Backend
- `POST /v1/managed-agents/{id}/channels` — bind a channel (creates binding + starts daemon/webhook)
- `DELETE /v1/managed-agents/{id}/channels/{binding_id}` — unbind (stops daemon/webhook)
- `AgentManager.add_channel_binding()` already exists — wire it to the new endpoint
- For iMessage: the endpoint starts/stops the `imessage_daemon` via the existing `run_daemon`/`stop_daemon` functions
- For Slack/WhatsApp/SMS: the endpoint updates `ChannelBridge` channel config
### No new daemon code
`imessage_daemon.py`, `ChannelBridge`, `webhook_routes.py`, and `channels_cmd.py` already exist. This sub-project wires them to the frontend UI.
---
## Sub-project 2: Seamless Connector Setup
### What it does
Replace the current generic "paste token" connect flow with per-connector step-by-step guides. Two patterns:
**Pattern A — Step-by-step guide (for token/password auth):**
Numbered steps with direct links to settings pages, then paste credentials.
**Pattern B — One-click OAuth + manual fallback:**
Primary "Authorize" button opens the service in browser. Manual token paste as secondary option.
### Per-connector flows
| Connector | Pattern | UX |
|-----------|---------|-----|
| Gmail IMAP | A | Steps: enable 2FA → generate app password → paste email:password |
| Outlook | A | Steps: enable 2FA → generate app password → paste email:password |
| Slack | B | "Open Slack App Settings" button → paste bot token (xoxb-...) |
| Notion | B | "Open Notion Integrations" button → paste token (ntn_...) → remind to share pages |
| Granola | A | Steps: open Granola app → Settings → API → paste key (grn_...) |
| Google Drive | B | "Authorize" opens OAuth consent URL → localhost callback captures code → exchanges for tokens |
| Google Calendar | B | Same as Drive (shared OAuth client, different scope) |
| Google Contacts | B | Same as Drive (shared OAuth client, different scope) |
| Obsidian | Filesystem | Path input field |
| Apple Notes | Auto | Check Full Disk Access, auto-connect |
| iMessage | Auto | Check Full Disk Access, auto-connect |
### Google OAuth proper flow (new)
The current Gmail OAuth connector saves the raw auth code without exchanging it. Fix:
1. Add `OAuthCallbackServer` — a tiny localhost HTTP server at `http://localhost:8789/callback` that:
- Waits for Google to redirect with `?code=...`
- Exchanges the code for access_token + refresh_token via `POST https://oauth2.googleapis.com/token`
- Stores tokens in `~/.openjarvis/connectors/{connector}.json`
- Returns a "Success! You can close this tab" page to the browser
2. Update `oauth.py` with `exchange_google_token(code, client_id, client_secret, redirect_uri)` function
3. Update `handle_callback()` in Gmail, Drive, Calendar, Contacts connectors to use the exchange flow
4. Google OAuth client ID/secret stored in `~/.openjarvis/config.toml` under `[connectors.google]` — user provides their own from Google Cloud Console
### Frontend changes
In `SourceConnectFlow.tsx`, replace the generic OAuth panel with connector-specific panels:
- Each connector gets its own instructions component
- Step-by-step connectors show numbered cards with links
- OAuth connectors show the "Authorize" button + manual fallback input
- All connectors show a "Read-only access · No data leaves your device" badge
### What does NOT change
- SourcePicker (already lists all connectors)
- IngestDashboard (already shows progress)
- ReadyScreen (already shows suggestions)
- Backend connector sync logic (already works)
- KnowledgeStore / ingestion pipeline
## Test Plan
### Channels Tab
- Channels tab appears on all agents (not just DeepResearch)
- Binding iMessage channel starts daemon and shows "Active" status
- Unbinding stops daemon
- Channel status updates in real-time
- Multiple agents can bind different channels
### Connector Setup
- Gmail IMAP: step-by-step flow → paste credentials → connected
- Notion: one-click opens integration page → paste token → connected
- Slack: paste bot token → connected
- Google Drive: OAuth consent URL opens → localhost callback exchanges token → connected
- Setup wizard completes with all connected sources ingested
- Read-only guarantee maintained across all connectors
@@ -1,162 +0,0 @@
# Personal DeepResearch Agent Template — Desktop + Browser + CLI
## Goal
Register "Personal Deep Research" as a default agent template so users can create it from the Agents tab in the desktop/browser app, chat with it via streaming SSE, and use it from the CLI. All read-only — no writes to any connector.
## Scope
**In scope:**
- New template TOML: `personal_deep_research.toml`
- Wire DeepResearch tools in `_stream_managed_agent()` when `agent_type == "deep_research"`
- Ensure KnowledgeStore at `~/.openjarvis/knowledge.db` is loaded when the agent runs
- Verify end-to-end: create from template in Agents tab → send message → get streamed cited report
**Out of scope:**
- Google OAuth (Sub-project 2)
- Channel listeners (Sub-project 3)
- New frontend components (existing Agents tab + chat UI work as-is)
## Architecture
### Template File
`src/openjarvis/agents/templates/personal_deep_research.toml`:
```toml
[template]
id = "personal_deep_research"
name = "Personal Deep Research"
description = "Search across your emails, messages, meeting notes, and documents with multi-hop retrieval and cited reports. Uses BM25 keyword search, SQL aggregation, and LM-powered semantic scanning."
agent_type = "deep_research"
schedule_type = "manual"
tools = ["knowledge_search", "knowledge_sql", "scan_chunks", "think"]
max_turns = 8
temperature = 0.3
max_tokens = 4096
```
This is auto-discovered by `AgentManager.list_templates()` and appears in the Agents tab wizard alongside the existing templates (code_reviewer, inbox_triager, research_monitor).
### Server-Side Tool Wiring
In `_stream_managed_agent()` (agent_manager_routes.py), when the agent type is `deep_research`, build the tools from the shared KnowledgeStore:
```python
if agent_record["agent_type"] == "deep_research":
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
from openjarvis.tools.scan_chunks import ScanChunksTool
from openjarvis.tools.think import ThinkTool
store = KnowledgeStore() # defaults to ~/.openjarvis/knowledge.db
retriever = TwoStageRetriever(store)
model = agent_record.get("config", {}).get("model") or engine_model
tools = [
KnowledgeSearchTool(retriever=retriever),
KnowledgeSQLTool(store=store),
ScanChunksTool(store=store, engine=engine, model=model),
ThinkTool(),
]
```
These tools are passed to `DeepResearchAgent(engine=engine, model=model, tools=tools)` when instantiating the agent for this request.
### Flow
```
User clicks "Personal Deep Research" template in Agents tab
→ POST /v1/managed-agents {template_id: "personal_deep_research", name: "My Research"}
→ AgentManager.create_from_template() → stores agent record in SQLite
User types question in chat
→ POST /v1/managed-agents/{id}/messages {content: "...", stream: true}
→ _stream_managed_agent() detects agent_type == "deep_research"
→ Builds 4 tools from KnowledgeStore
→ Instantiates DeepResearchAgent with tools
→ agent.run(query) → multi-hop tool calls
→ Streams response as SSE word-by-word
→ Frontend renders streamed markdown with citations
```
### CLI Alias
Add `jarvis research` as a convenience alias that runs `jarvis deep-research-setup`. Same behavior, shorter command.
### Multi-Channel Messaging — iMessage, Slack, WhatsApp, SMS
Users can message their DeepResearch agent from their phone via iMessage, Slack, WhatsApp, or SMS. This builds on the `ChannelBridge` + webhook architecture from PR #78 (open-jarvis/OpenJarvis#78).
**Three options for iMessage (user picks during setup):**
1. **AppleScript daemon (free, no external services)** — Background daemon on the Mac polls `chat.db` for new messages in a designated conversation, routes to agent, responds via AppleScript. Requires Mac to be running.
2. **BlueBubbles (free, self-hosted)** — Install BlueBubbles on the Mac, which provides a REST API + webhook for iMessage. PR #78 already has webhook handler at `/webhooks/bluebubbles`. More reliable than AppleScript, supports media.
3. **Sendblue (paid cloud API)** — Cloud iMessage API, no Mac required for sending. Works from any server. Costs money but no local dependencies. We'd add a Sendblue webhook handler alongside the existing BlueBubbles one.
**Architecture (from PR #78):**
```
Phone sends message
→ Webhook hits /webhooks/{twilio|bluebubbles|whatsapp}
→ ChannelBridge.handle_incoming(sender, content, channel_type)
→ SessionStore tracks per-sender conversation
→ Routes to DeepResearchAgent.run(query)
→ Response sent back via channel adapter (same channel)
```
For the AppleScript daemon (option 1), the flow is:
```
iPhone sends iMessage → Mac Messages.app → chat.db
→ iMessageDaemon polls chat.db (read-only)
→ ChannelBridge.handle_incoming(sender, content, "imessage")
→ DeepResearchAgent.run(query)
→ AppleScript sends response via Messages.app
→ Response appears on iPhone
```
**What we build (from PR #78 + new):**
- Merge/rebase PR #78's `ChannelBridge`, `SessionStore`, `webhook_routes`, `auth_middleware`
- Add AppleScript-based iMessage daemon as an alternative to BlueBubbles
- Add `jarvis channels setup` CLI command to configure channel(s)
- Add `jarvis channels start` / `stop` / `status` for daemon lifecycle
- Wire `ChannelBridge` to route to DeepResearchAgent instead of generic `JarvisSystem.ask()`
**Finding the agent address (shown in CLI + Desktop + Browser):**
| Channel | How user finds the address |
|---------|---------------------------|
| iMessage (AppleScript) | `jarvis channels status` → "iMessage: listening on [chat name]" |
| iMessage (BlueBubbles) | `jarvis channels status` → "iMessage: via BlueBubbles at [url]" |
| Slack | Agent detail → Channels tab → shows Slack workspace + channel |
| WhatsApp | Agent detail → Channels tab → shows WhatsApp number |
| SMS (Twilio) | Agent detail → Channels tab → shows Twilio phone number |
**Read-only guarantee:** All channel adapters only READ incoming messages and SEND responses. No channel adapter modifies, deletes, or marks messages as read. The iMessage daemon reads chat.db in read-only mode (`?mode=ro`). Responses go through Messages.app (AppleScript) or BlueBubbles API, not by writing to chat.db.
## What Does NOT Change
- Frontend Agents tab UI (already supports templates)
- Frontend chat components (already support SSE streaming)
- Agent CRUD endpoints (already work)
- DeepResearchAgent class (already has all 4 tools + prompt)
- KnowledgeStore / ingestion pipeline (already populated)
- iMessage connector (read-only data source — separate from the iMessage agent channel)
## Test Plan
- Template appears in `GET /v1/templates` response
- Creating from template succeeds via `POST /v1/managed-agents`
- Sending a message with `stream: true` returns SSE chunks
- Agent uses knowledge_search/knowledge_sql/scan_chunks/think tools
- Response includes citations
- `jarvis research` CLI alias works
- iMessage daemon detects new messages in designated chat
- iMessage daemon sends response via AppleScript
- `jarvis channels status` shows correct state per channel
- ChannelBridge routes incoming webhook to DeepResearchAgent
- Webhook endpoints return correct HTTP responses (TwiML for Twilio, 200 for BlueBubbles/WhatsApp)
- SessionStore tracks per-sender conversations across channels
@@ -0,0 +1,119 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { Play, Pause, Volume2 } from 'lucide-react';
interface AudioPlayerProps {
src: string;
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function AudioPlayer({ src }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const toggle = useCallback(() => {
const el = audioRef.current;
if (!el) return;
if (playing) {
el.pause();
} else {
el.play();
}
setPlaying(!playing);
}, [playing]);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const onTime = () => setCurrentTime(el.currentTime);
const onMeta = () => setDuration(el.duration);
const onEnded = () => {
setPlaying(false);
setCurrentTime(0);
};
el.addEventListener('timeupdate', onTime);
el.addEventListener('loadedmetadata', onMeta);
el.addEventListener('ended', onEnded);
return () => {
el.removeEventListener('timeupdate', onTime);
el.removeEventListener('loadedmetadata', onMeta);
el.removeEventListener('ended', onEnded);
};
}, []);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const seek = (e: React.MouseEvent<HTMLDivElement>) => {
const el = audioRef.current;
if (!el || !duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
el.currentTime = pct * duration;
};
return (
<div
className="flex items-center gap-3 px-4 py-3 rounded-xl mb-3"
style={{
background: 'var(--color-surface, #f5f5f5)',
border: '1px solid var(--color-border, #e0e0e0)',
}}
>
<audio ref={audioRef} src={src} preload="metadata" />
<button
onClick={toggle}
className="flex items-center justify-center w-9 h-9 rounded-full transition-colors shrink-0"
style={{
background: 'var(--color-accent, #6366f1)',
color: 'white',
cursor: 'pointer',
}}
>
{playing ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
</button>
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<Volume2 size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<span
className="text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Morning Digest
</span>
</div>
<div
className="h-1.5 rounded-full cursor-pointer"
style={{ background: 'var(--color-bg-tertiary, #e0e0e0)' }}
onClick={seek}
>
<div
className="h-full rounded-full transition-all"
style={{
width: `${progress}%`,
background: 'var(--color-accent, #6366f1)',
}}
/>
</div>
<div
className="flex justify-between text-xs"
style={{ color: 'var(--color-text-tertiary)' }}
>
<span>{formatTime(currentTime)}</span>
<span>{duration > 0 ? formatTime(duration) : '--:--'}</span>
</div>
</div>
</div>
);
}
+16 -1
View File
@@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
import { Send, Square, Paperclip } from 'lucide-react';
import { useAppStore, generateId } from '../../lib/store';
import { streamChat } from '../../lib/sse';
import { fetchSavings } from '../../lib/api';
import { fetchSavings, getBase } from '../../lib/api';
import { MicButton } from './MicButton';
import { useSpeech } from '../../hooks/useSpeech';
import type { ChatMessage, ToolCallInfo, TokenUsage, MessageTelemetry } from '../../types';
@@ -257,12 +257,27 @@ export function InputArea() {
complexity_tier: complexity?.tier,
suggested_max_tokens: complexity?.suggested_max_tokens,
};
// Check if the response has digest audio available
let audioMeta: { url: string } | undefined;
try {
const digestRes = await fetch(`${getBase()}/api/digest`);
if (digestRes.ok) {
const digest = await digestRes.json();
if (digest.audio_available) {
audioMeta = { url: `${getBase()}/api/digest/audio` };
}
}
} catch {
// Not a digest response or server unavailable — skip
}
updateLastAssistant(
convId,
accumulatedContent,
toolCalls.length > 0 ? toolCalls : undefined,
usage,
telemetry,
audioMeta,
);
if (timerRef.current) {
clearInterval(timerRef.current);
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import 'katex/dist/katex.min.css';
import { Copy, Check } from 'lucide-react';
import { AudioPlayer } from './AudioPlayer';
import { ToolCallCard } from './ToolCallCard';
import { XRayFooter } from './XRayFooter';
import type { ChatMessage } from '../../types';
@@ -131,6 +132,9 @@ export function MessageBubble({ message }: Props) {
</div>
)}
{/* Audio player (e.g. morning digest) */}
{message.audio?.url && <AudioPlayer src={message.audio.url} />}
{/* Assistant message */}
{cleanContent && (
<div className="prose max-w-none">
@@ -8,7 +8,8 @@ import {
Loader2,
} from 'lucide-react';
import { SOURCE_CATALOG } from '../../types/connectors';
import { connectSource } from '../../lib/connectors-api';
import { connectSource, getConnector } from '../../lib/connectors-api';
import { getBase } from '../../lib/api';
import type { ConnectRequest, ConnectorMeta } from '../../types/connectors';
// ---------------------------------------------------------------------------
@@ -138,89 +139,71 @@ function FilesystemPanel({
function OAuthPanel({
displayName,
authUrl,
connectorId,
onConnect,
onSkip,
isConnecting,
}: {
displayName: string;
authUrl?: string;
connectorId: string;
onConnect: (req: ConnectRequest) => void;
onSkip: () => void;
isConnecting: boolean;
}) {
const [token, setToken] = useState('');
const [phase, setPhase] = useState<'start' | 'paste'>('start');
const [waiting, setWaiting] = useState(false);
const openBrowser = () => {
if (authUrl) {
window.open(authUrl, '_blank');
}
setPhase('paste');
const startOAuth = () => {
// Open the server's OAuth start endpoint which redirects to the provider
const oauthUrl = `${getBase()}/v1/connectors/${encodeURIComponent(connectorId)}/oauth/start`;
window.open(oauthUrl, '_blank', 'width=600,height=700');
setWaiting(true);
// Poll for connection status
const interval = setInterval(async () => {
try {
const info = await getConnector(connectorId);
if (info.connected) {
clearInterval(interval);
setWaiting(false);
onConnect({});
}
} catch {
// ignore polling errors
}
}, 2000);
// Stop polling after 3 minutes
setTimeout(() => {
clearInterval(interval);
setWaiting(false);
}, 180000);
};
return (
<div className="flex flex-col gap-4">
{phase === 'start' ? (
<>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Authorize OpenJarvis to access your {displayName} account.
</p>
<button
onClick={openBrowser}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium self-start transition-all"
style={{
background: 'var(--color-accent)',
color: 'white',
}}
>
<ExternalLink size={14} />
Open in browser
</button>
</>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
{waiting
? `Waiting for ${displayName} authorization... Complete it in the browser window.`
: `Connect your ${displayName} account with one click.`}
</p>
{waiting ? (
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--color-accent)' }}>
<Loader2 size={16} className="animate-spin" />
Waiting for authorization...
</div>
) : (
<>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
After authorizing, paste the token or code below.
</p>
<textarea
value={token}
onChange={(e) => setToken(e.target.value)}
rows={3}
placeholder="Paste auth token or code here..."
className="w-full px-3 py-2 rounded-lg text-sm outline-none resize-none font-mono"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
/>
<div className="flex gap-2">
<button
onClick={() => onConnect({ token, code: token })}
disabled={!token.trim() || isConnecting}
className="px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all"
style={{
background: token.trim() ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
color: token.trim() ? 'white' : 'var(--color-text-tertiary)',
cursor: token.trim() && !isConnecting ? 'pointer' : 'not-allowed',
}}
>
{isConnecting && <Loader2 size={14} className="animate-spin" />}
Confirm
</button>
<button
onClick={() => setPhase('start')}
className="px-4 py-2 rounded-lg text-sm font-medium transition-all"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
Back
</button>
</div>
</>
<button
onClick={startOAuth}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium self-start transition-all"
style={{
background: 'var(--color-accent)',
color: 'white',
}}
>
<ExternalLink size={14} />
Connect {displayName}
</button>
)}
<button
onClick={onSkip}
@@ -589,6 +572,7 @@ export function SourceConnectFlow({
<OAuthPanel
displayName={activeCard.display_name}
authUrl={undefined}
connectorId={activeEntry.id}
onConnect={(req) => handleConnect(activeEntry.id, req)}
onSkip={() => handleSkip(activeEntry.id)}
isConnecting={activeEntry.state === 'connecting'}
+3
View File
@@ -149,6 +149,7 @@ interface AppState {
toolCalls?: ToolCallInfo[],
usage?: TokenUsage,
telemetry?: MessageTelemetry,
audio?: { url: string },
) => void;
setStreamState: (state: Partial<StreamState>) => void;
resetStream: () => void;
@@ -337,6 +338,7 @@ export const useAppStore = create<AppState>((set, get) => {
toolCalls?: ToolCallInfo[],
usage?: TokenUsage,
telemetry?: MessageTelemetry,
audio?: { url: string },
) => {
const store = loadConversations();
const conv = store.conversations[conversationId];
@@ -347,6 +349,7 @@ export const useAppStore = create<AppState>((set, get) => {
if (toolCalls) lastMsg.toolCalls = toolCalls;
if (usage) lastMsg.usage = usage;
if (telemetry) lastMsg.telemetry = telemetry;
if (audio) lastMsg.audio = audio;
conv.updatedAt = Date.now();
saveConversations(store);
set({ messages: [...conv.messages] });
+1
View File
@@ -69,6 +69,7 @@ export interface ChatMessage {
toolCalls?: ToolCallInfo[];
usage?: TokenUsage;
telemetry?: MessageTelemetry;
audio?: { url: string };
}
export interface Conversation {
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Run OAuth flows for Google, Strava, and Spotify in sequence.
Usage:
uv run python scripts/oauth_all.py [--google] [--strava] [--spotify]
uv run python scripts/oauth_all.py # runs all three
"""
from __future__ import annotations
import argparse
import json
import os
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
CONFIG_DIR = Path.home() / ".openjarvis" / "connectors"
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CALLBACK_PORT = 8789
REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}/callback"
# ── Credentials (read from stored files, then env vars) ──────────────────────
def _load_creds(filename: str) -> Dict[str, str]:
"""Load client_id/client_secret from a stored credential file."""
path = CONFIG_DIR / filename
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
return {
"client_id": data.get("client_id", ""),
"client_secret": data.get("client_secret", ""),
}
except (json.JSONDecodeError, OSError):
pass
return {"client_id": "", "client_secret": ""}
_google = _load_creds("google.json")
GOOGLE_CLIENT_ID = os.environ.get("OPENJARVIS_GOOGLE_CLIENT_ID", "") or _google["client_id"]
GOOGLE_CLIENT_SECRET = os.environ.get("OPENJARVIS_GOOGLE_CLIENT_SECRET", "") or _google["client_secret"]
_strava = _load_creds("strava.json")
STRAVA_CLIENT_ID = os.environ.get("OPENJARVIS_STRAVA_CLIENT_ID", "") or _strava["client_id"]
STRAVA_CLIENT_SECRET = os.environ.get("OPENJARVIS_STRAVA_CLIENT_SECRET", "") or _strava["client_secret"]
_spotify = _load_creds("spotify.json")
SPOTIFY_CLIENT_ID = os.environ.get("OPENJARVIS_SPOTIFY_CLIENT_ID", "") or _spotify["client_id"]
SPOTIFY_CLIENT_SECRET = os.environ.get("OPENJARVIS_SPOTIFY_CLIENT_SECRET", "") or _spotify["client_secret"]
# ── Generic OAuth helpers ────────────────────────────────────────────────────
def _wait_for_code(port: int = CALLBACK_PORT, timeout: int = 120) -> str:
"""Start a localhost server and wait for the OAuth callback with ?code=."""
auth_code: List[str] = []
error: List[str] = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
params = parse_qs(urlparse(self.path).query)
if "code" in params:
auth_code.append(params["code"][0])
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h2>Success!</h2>"
b"<p>You can close this tab.</p></body></html>"
)
else:
error.append(params.get("error", ["unknown"])[0])
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<html><body><h2>Failed</h2></body></html>")
def log_message(self, *args: Any) -> None:
pass
server = HTTPServer(("127.0.0.1", port), Handler)
server.timeout = timeout
while not auth_code and not error:
server.handle_request()
server.server_close()
if error:
raise RuntimeError(f"OAuth error: {error[0]}")
if not auth_code:
raise RuntimeError("OAuth timed out")
return auth_code[0]
def _save(path: Path, data: Dict[str, Any]) -> None:
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
path.chmod(0o600)
print(f" Saved tokens to {path}")
# ── Google ───────────────────────────────────────────────────────────────────
def do_google() -> None:
print("\n=== Google OAuth (Drive, Calendar, Contacts, Gmail, Tasks) ===")
scopes = [
"openid", "email", "profile",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/tasks.readonly",
]
url = (
"https://accounts.google.com/o/oauth2/v2/auth?"
+ urlencode({
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": " ".join(scopes),
"access_type": "offline",
"prompt": "consent",
})
)
print(f" Opening browser...")
webbrowser.open(url)
code = _wait_for_code()
resp = httpx.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code",
},
timeout=30.0,
)
resp.raise_for_status()
tokens = resp.json()
payload = {
"access_token": tokens["access_token"],
"refresh_token": tokens.get("refresh_token", ""),
"token_type": tokens.get("token_type", "Bearer"),
"expires_in": tokens.get("expires_in", 3600),
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
}
# Save to shared + all connector-specific files
for name in ["google", "gdrive", "gcalendar", "gcontacts", "gmail", "google_tasks"]:
_save(CONFIG_DIR / f"{name}.json", payload)
print(" Google OAuth complete!")
# ── Strava ───────────────────────────────────────────────────────────────────
def do_strava() -> None:
print("\n=== Strava OAuth ===")
url = (
"https://www.strava.com/oauth/authorize?"
+ urlencode({
"client_id": STRAVA_CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": "activity:read_all",
})
)
print(f" Opening browser...")
webbrowser.open(url)
code = _wait_for_code()
resp = httpx.post(
"https://www.strava.com/oauth/token",
data={
"client_id": STRAVA_CLIENT_ID,
"client_secret": STRAVA_CLIENT_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=30.0,
)
resp.raise_for_status()
tokens = resp.json()
payload = {
"access_token": tokens["access_token"],
"refresh_token": tokens.get("refresh_token", ""),
"client_id": STRAVA_CLIENT_ID,
"client_secret": STRAVA_CLIENT_SECRET,
}
_save(CONFIG_DIR / "strava.json", payload)
print(" Strava OAuth complete!")
# ── Spotify ──────────────────────────────────────────────────────────────────
def _make_self_signed_cert() -> tuple[str, str]:
"""Generate a temporary self-signed cert for localhost HTTPS."""
import ssl
import subprocess
import tempfile
cert_dir = Path(tempfile.mkdtemp())
key_path = str(cert_dir / "key.pem")
cert_path = str(cert_dir / "cert.pem")
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", key_path, "-out", cert_path,
"-days", "1", "-nodes",
"-subj", "/CN=localhost",
],
capture_output=True, check=True,
)
return cert_path, key_path
def _wait_for_code_https(port: int = 8888, timeout: int = 120) -> str:
"""Like _wait_for_code but serves over HTTPS with a self-signed cert."""
import ssl
cert_path, key_path = _make_self_signed_cert()
auth_code: List[str] = []
error: List[str] = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
params = parse_qs(urlparse(self.path).query)
if "code" in params:
auth_code.append(params["code"][0])
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h2>Success!</h2>"
b"<p>You can close this tab.</p></body></html>"
)
else:
error.append(params.get("error", ["unknown"])[0])
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<html><body><h2>Failed</h2></body></html>")
def log_message(self, *args: Any) -> None:
pass
server = HTTPServer(("127.0.0.1", port), Handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert_path, key_path)
server.socket = ctx.wrap_socket(server.socket, server_side=True)
server.timeout = timeout
while not auth_code and not error:
server.handle_request()
server.server_close()
if error:
raise RuntimeError(f"OAuth error: {error[0]}")
if not auth_code:
raise RuntimeError("OAuth timed out")
return auth_code[0]
def do_spotify() -> None:
print("\n=== Spotify OAuth ===")
spotify_port = 8888
spotify_redirect = f"http://127.0.0.1:{spotify_port}/callback"
url = (
"https://accounts.spotify.com/authorize?"
+ urlencode({
"client_id": SPOTIFY_CLIENT_ID,
"redirect_uri": spotify_redirect,
"response_type": "code",
"scope": "user-read-recently-played",
})
)
print(f" Opening browser...")
webbrowser.open(url)
code = _wait_for_code(port=spotify_port)
import base64
auth_header = base64.b64encode(
f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_CLIENT_SECRET}".encode()
).decode()
resp = httpx.post(
"https://accounts.spotify.com/api/token",
headers={"Authorization": f"Basic {auth_header}"},
data={
"code": code,
"redirect_uri": spotify_redirect,
"grant_type": "authorization_code",
},
timeout=30.0,
)
resp.raise_for_status()
tokens = resp.json()
payload = {
"access_token": tokens["access_token"],
"refresh_token": tokens.get("refresh_token", ""),
"client_id": SPOTIFY_CLIENT_ID,
"client_secret": SPOTIFY_CLIENT_SECRET,
}
_save(CONFIG_DIR / "spotify.json", payload)
print(" Spotify OAuth complete!")
# ── Main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run OAuth flows for OpenJarvis connectors")
parser.add_argument("--google", action="store_true", help="Only Google")
parser.add_argument("--strava", action="store_true", help="Only Strava")
parser.add_argument("--spotify", action="store_true", help="Only Spotify")
args = parser.parse_args()
run_all = not (args.google or args.strava or args.spotify)
if run_all or args.google:
do_google()
if run_all or args.strava:
do_strava()
if run_all or args.spotify:
do_spotify()
print("\nDone! All OAuth flows complete.")
+5
View File
@@ -74,6 +74,11 @@ try:
except ImportError:
pass
try:
import openjarvis.agents.morning_digest # noqa: F401
except ImportError:
pass
# Registry alias: "react" -> NativeReActAgent (for backward compat)
try:
from openjarvis.core.registry import AgentRegistry
+153
View File
@@ -0,0 +1,153 @@
"""DigestStore — SQLite-backed storage for pre-computed digest artifacts."""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
@dataclass
class DigestArtifact:
"""A pre-computed morning digest ready for delivery."""
text: str
audio_path: Path
sections: Dict[str, str]
sources_used: List[str]
generated_at: datetime
model_used: str
voice_used: str
quality_score: float = 0.0
evaluator_feedback: str = ""
class DigestStore:
"""SQLite store for digest artifacts."""
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(Path.home() / ".openjarvis" / "digest.db")
self._db_path = db_path
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS digests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
audio_path TEXT NOT NULL,
sections TEXT NOT NULL,
sources_used TEXT NOT NULL,
generated_at TEXT NOT NULL,
model_used TEXT NOT NULL,
voice_used TEXT NOT NULL,
quality_score REAL NOT NULL DEFAULT 0.0,
evaluator_feedback TEXT NOT NULL DEFAULT ''
)
"""
)
self._migrate()
self._conn.commit()
def _migrate(self) -> None:
"""Add columns introduced after the initial schema."""
existing = {
row[1]
for row in self._conn.execute("PRAGMA table_info(digests)").fetchall()
}
if "quality_score" not in existing:
self._conn.execute(
"ALTER TABLE digests ADD COLUMN quality_score REAL NOT NULL DEFAULT 0.0"
)
if "evaluator_feedback" not in existing:
self._conn.execute(
"ALTER TABLE digests"
" ADD COLUMN evaluator_feedback TEXT NOT NULL DEFAULT ''"
)
def save(self, artifact: DigestArtifact) -> None:
"""Save a digest artifact."""
self._conn.execute(
"""
INSERT INTO digests
(text, audio_path, sections, sources_used,
generated_at, model_used, voice_used,
quality_score, evaluator_feedback)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
artifact.text,
str(artifact.audio_path),
json.dumps(artifact.sections),
json.dumps(artifact.sources_used),
artifact.generated_at.isoformat(),
artifact.model_used,
artifact.voice_used,
artifact.quality_score,
artifact.evaluator_feedback,
),
)
self._conn.commit()
def _row_to_artifact(self, row: tuple) -> DigestArtifact:
return DigestArtifact(
text=row[0],
audio_path=Path(row[1]),
sections=json.loads(row[2]),
sources_used=json.loads(row[3]),
generated_at=datetime.fromisoformat(row[4]),
model_used=row[5],
voice_used=row[6],
quality_score=row[7] if len(row) > 7 else 0.0,
evaluator_feedback=row[8] if len(row) > 8 else "",
)
def get_latest(self) -> Optional[DigestArtifact]:
"""Return the most recent digest, or None."""
row = self._conn.execute(
"SELECT text, audio_path, sections, sources_used,"
" generated_at, model_used, voice_used,"
" quality_score, evaluator_feedback"
" FROM digests ORDER BY id DESC LIMIT 1"
).fetchone()
if row is None:
return None
return self._row_to_artifact(row)
def get_today(self, timezone_name: str = "UTC") -> Optional[DigestArtifact]:
"""Return today's digest if it exists, or None."""
try:
from zoneinfo import ZoneInfo
today = datetime.now(ZoneInfo(timezone_name)).strftime("%Y-%m-%d")
except ImportError:
today = datetime.now().strftime("%Y-%m-%d")
row = self._conn.execute(
"SELECT text, audio_path, sections, sources_used,"
" generated_at, model_used, voice_used,"
" quality_score, evaluator_feedback"
" FROM digests WHERE generated_at LIKE ? ORDER BY id DESC LIMIT 1",
(f"{today}%",),
).fetchone()
if row is None:
return None
return self._row_to_artifact(row)
def history(self, limit: int = 10) -> List[DigestArtifact]:
"""Return the N most recent digests."""
rows = self._conn.execute(
"SELECT text, audio_path, sections, sources_used,"
" generated_at, model_used, voice_used,"
" quality_score, evaluator_feedback"
" FROM digests ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [self._row_to_artifact(r) for r in rows]
def close(self) -> None:
self._conn.close()
+248
View File
@@ -0,0 +1,248 @@
"""Morning Digest Agent — synthesizes a daily briefing from multiple sources.
Thin orchestrator that delegates to digest_collect (data fetching),
the LLM (narrative synthesis), and text_to_speech (audio generation).
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.agents.digest_store import DigestArtifact, DigestStore
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall
def _load_persona(persona_name: str) -> str:
"""Load a persona prompt file by name."""
search_paths = [
Path("configs/openjarvis/prompts/personas") / f"{persona_name}.md",
Path.home() / ".openjarvis" / "prompts" / "personas" / f"{persona_name}.md",
]
for p in search_paths:
if p.exists():
return p.read_text(encoding="utf-8")
return ""
@AgentRegistry.register("morning_digest")
class MorningDigestAgent(ToolUsingAgent):
"""Pre-compute a daily digest from configured data sources."""
agent_id = "morning_digest"
def __init__(self, *args: Any, **kwargs: Any) -> None:
# Extract digest-specific kwargs before passing to parent
self._persona = kwargs.pop("persona", "jarvis")
self._sections = kwargs.pop(
"sections", ["messages", "calendar", "health", "world"]
)
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 + 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"
f"The user's preferred honorific is: {honorific}\n\n"
"You receive structured data from the user's connected services. "
"The data has ALREADY been collected — it appears in the user "
"message. You do NOT fetch anything yourself.\n\n"
"Produce a 2-4 minute spoken briefing in DECREASING order of "
"importance:\n\n"
"1. GREETING + PRIORITIES — Open with the honorific and "
"immediately state what needs attention: overdue tasks, today's "
"deadlines, events requiring preparation. Connect related items "
"('Your rebuttals are overdue and you have a dinner at 6, so "
"I'd tackle those first').\n\n"
"2. SCHEDULE — Today's upcoming events with time context: 'You "
"have 3 hours before your next meeting.' Skip past events.\n\n"
"3. MESSAGES — Triage across ALL channels (email, texts, Slack):\n"
" - First: messages from real people needing a REPLY or DECISION\n"
" - Second: messages containing deadlines or action items\n"
" - Last: brief acknowledgment of casual threads ('Your group "
"chat has been lively but nothing requiring a response')\n"
" - SKIP automated emails, newsletters, and marketing entirely\n"
" - Quote relevant message text when it helps\n\n"
"4. HEALTH — Interpret trends, not raw numbers. 'Your sleep has "
"improved three nights running and your readiness is strong'"
"not 'HRV 53, HR 56.' If multiple days of data, compare.\n\n"
"5. WORLD — Weather forecast, top news (AI/tech, business, "
"general). Skip if no data.\n\n"
"6. CLOSING — One forward-looking sentence with the honorific.\n\n"
"ABSOLUTE RULES (violations are unacceptable):\n"
"- ONLY facts from the data. Zero hallucination.\n"
"- NEVER mention disconnected or unavailable sources.\n"
"- NEVER state raw health numbers. Say 'your sleep was solid' "
"NOT 'heart rate 56 bpm' or 'HRV 53' or '6000 steps' or "
"'readiness 82'. Interpret, never enumerate.\n"
"- NEVER describe actions you are taking.\n"
"- Acknowledge every source that returned data, even briefly.\n"
"- No markdown, emojis, bullets, or headers.\n"
"- STRICT LIMIT: 200 words. Be concise."
)
def _resolve_sources(self) -> List[str]:
"""Get the list of connector IDs to query."""
default_source_map = {
"messages": [
"gmail",
"slack",
"google_tasks",
"imessage",
"github_notifications",
],
"calendar": ["gcalendar"],
"health": ["oura", "apple_health"],
"world": ["weather", "hackernews", "news_rss"],
"music": ["spotify", "apple_music"],
}
sources = set()
for section in self._sections:
section_sources = self._section_sources.get(
section, default_source_map.get(section, [])
)
sources.update(section_sources)
return list(sources)
def run(
self,
input: str,
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
self._emit_turn_start(input)
# Step 1: Collect data from connectors
sources = self._resolve_sources()
collect_call = ToolCall(
id="digest-collect-1",
name="digest_collect",
arguments=json.dumps({"sources": sources, "hours_back": 24}),
)
collect_result = self._executor.execute(collect_call)
collected_data = collect_result.content
# Step 2: Synthesize narrative via LLM
system_prompt = self._build_system_prompt()
messages = [
Message(role=Role.SYSTEM, content=system_prompt),
Message(
role=Role.USER,
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"- Priority-first, connect related items\n"
f"- For health: say 'solid', 'improving', 'dipped' "
f"— NEVER say any number (no 82, no 56, no 6000)\n"
f"- Do NOT invent reasons for health changes\n"
f"- Do NOT mention disconnected sources\n"
f"- Do NOT repeat the greeting in your closing\n"
f"- Use the honorific ONLY 2-3 times total\n"
f"- Skip notifications from the user themselves\n"
f"- STRICT LIMIT: 200-250 words maximum"
),
),
]
result = self._generate(messages)
narrative = self._strip_think_tags(result.get("content", ""))
# Step 2b: Self-evaluate and optionally regenerate
quality_score = 0.0
evaluator_feedback = ""
try:
from openjarvis.agents.digest_evaluator import DigestEvaluator
evaluator = DigestEvaluator(self._engine, self._model)
quality_score, evaluator_feedback = evaluator.evaluate(
collected_data, narrative
)
if quality_score < 7.0 and evaluator_feedback:
# Regenerate with feedback
messages.append(
Message(
role=Role.USER,
content=(
f"Your briefing scored {quality_score:.1f}/10. "
f"Feedback: {evaluator_feedback}\n"
f"Please revise the briefing addressing this feedback."
),
)
)
result = self._generate(messages)
narrative = self._strip_think_tags(result.get("content", ""))
except Exception: # noqa: BLE001
pass # Evaluator failure shouldn't block digest delivery
# Step 3: Generate audio via TTS
# Strip any markdown that slipped through (##, *, -, etc.)
import re
tts_text = re.sub(r"^#{1,6}\s+", "", narrative, flags=re.MULTILINE)
tts_text = re.sub(r"^\s*[-*•]\s+", "", tts_text, flags=re.MULTILINE)
tts_text = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", tts_text)
tts_text = tts_text.strip()
tts_call = ToolCall(
id="digest-tts-1",
name="text_to_speech",
arguments=json.dumps(
{
"text": tts_text,
"voice_id": self._voice_id,
"backend": self._tts_backend,
"speed": self._voice_speed,
}
),
)
tts_result = self._executor.execute(tts_call)
audio_path = (
tts_result.metadata.get("audio_path", "") if tts_result.success else ""
)
# Step 4: Store the artifact
artifact = DigestArtifact(
text=narrative,
audio_path=Path(audio_path) if audio_path else Path(""),
sections={},
sources_used=sources,
generated_at=datetime.now(),
model_used=self._model,
voice_used=self._voice_id,
quality_score=quality_score,
evaluator_feedback=evaluator_feedback,
)
store = DigestStore(db_path=self._digest_store_path)
store.save(artifact)
store.close()
self._emit_turn_end(turns=1)
return AgentResult(
content=narrative,
tool_results=[collect_result, tts_result],
turns=1,
metadata={
"audio_path": audio_path,
"sources_used": sources,
},
)
+2 -1
View File
@@ -194,7 +194,8 @@ class NativeReActAgent(ToolUsingAgent):
# Max turns exceeded
msg_dicts = [_message_to_dict(m) for m in messages]
return self._max_turns_result(
all_tool_results, turns,
all_tool_results,
turns,
metadata={**total_usage, "messages": msg_dicts},
)
+3 -9
View File
@@ -57,15 +57,11 @@ class SendBlueChannel(BaseChannel):
webhook_secret: str = "",
bus: Optional[EventBus] = None,
) -> None:
self._api_key_id = api_key_id or os.environ.get(
"SENDBLUE_API_KEY_ID", ""
)
self._api_key_id = api_key_id or os.environ.get("SENDBLUE_API_KEY_ID", "")
self._api_secret_key = api_secret_key or os.environ.get(
"SENDBLUE_API_SECRET_KEY", ""
)
self._from_number = from_number or os.environ.get(
"SENDBLUE_FROM_NUMBER", ""
)
self._from_number = from_number or os.environ.get("SENDBLUE_FROM_NUMBER", "")
self._webhook_secret = webhook_secret
self._bus = bus
self._handlers: List[ChannelHandler] = []
@@ -215,9 +211,7 @@ class SendBlueChannel(BaseChannel):
# -- internal helpers -------------------------------------------------------
def _publish_sent(
self, channel: str, content: str, conversation_id: str
) -> None:
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
+12 -5
View File
@@ -63,7 +63,10 @@ def run_slack_daemon(
engine = OllamaEngine()
tools = _build_deep_research_tools(engine=engine, model=model)
agent = DeepResearchAgent(
engine=engine, model=model, tools=tools, max_turns=5,
engine=engine,
model=model,
tools=tools,
max_turns=5,
)
logger.info("Slack daemon: agent ready with %d tools", len(tools))
@@ -143,11 +146,15 @@ def start_slack_daemon(
proc = subprocess.Popen(
[
sys.executable, "-m",
sys.executable,
"-m",
"openjarvis.channels.slack_daemon",
"--bot-token", bot_token,
"--app-token", app_token,
"--model", model,
"--bot-token",
bot_token,
"--app-token",
app_token,
"--model",
model,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
+2
View File
@@ -17,6 +17,7 @@ from openjarvis.cli.config_cmd import config
from openjarvis.cli.connect_cmd import connect
from openjarvis.cli.daemon_cmd import restart, start, status, stop
from openjarvis.cli.deep_research_setup_cmd import deep_research_setup
from openjarvis.cli.digest_cmd import digest
from openjarvis.cli.doctor_cmd import doctor
from openjarvis.cli.eval_cmd import eval_group
from openjarvis.cli.feedback_cmd import feedback_group
@@ -94,6 +95,7 @@ cli.add_command(registry, "registry")
cli.add_command(config, "config")
cli.add_command(scan, "scan")
cli.add_command(connect, "connect")
cli.add_command(digest, "digest")
cli.add_command(deep_research_setup, "deep-research-setup")
cli.add_command(deep_research_setup, "research")
+1 -3
View File
@@ -138,9 +138,7 @@ def _get_channel(
import os
kwargs["api_key_id"] = os.environ.get("SENDBLUE_API_KEY_ID", "")
kwargs["api_secret_key"] = os.environ.get(
"SENDBLUE_API_SECRET_KEY", ""
)
kwargs["api_secret_key"] = os.environ.get("SENDBLUE_API_SECRET_KEY", "")
kwargs["from_number"] = os.environ.get("SENDBLUE_FROM_NUMBER", "")
sbc = getattr(config.channel, "sendblue", None)
if sbc:
+55 -8
View File
@@ -95,18 +95,66 @@ def _connect_source(registry: object, source: str, path: str = "") -> None:
)
elif auth_type == "oauth":
# OAuth connectors need browser-based auth
# OAuth connectors — auto-open browser + catch callback
from openjarvis.connectors.oauth import (
get_client_credentials,
get_provider_for_connector,
run_connector_oauth,
save_client_credentials,
)
try:
instance = connector_cls()
url = instance.auth_url()
console.print(f"[cyan]Open this URL to authorise {source}:[/cyan]")
console.print(url)
code = click.prompt("Enter the authorisation code")
instance.handle_callback(code)
if instance.is_connected():
console.print(f"[green]{source} is already connected.[/green]")
return
provider = get_provider_for_connector(source)
if provider is None:
console.print(f"[red]No OAuth provider configured for {source}.[/red]")
return
client_id, client_secret = get_client_credentials(provider)
if not client_id or not client_secret:
console.print(f"[cyan]First-time setup for {source}.[/cyan]")
console.print(
f"[yellow]Create an OAuth app at: {provider.setup_url}[/yellow]"
)
console.print(f"[dim]{provider.setup_hint}[/dim]")
client_id = click.prompt("Client ID")
client_secret = click.prompt("Client Secret")
save_client_credentials(provider, client_id, client_secret)
run_connector_oauth(source, client_id, client_secret)
console.print(f"[green]{source} authorised successfully.[/green]")
except Exception as exc: # noqa: BLE001
console.print(f"[red]OAuth flow failed for {source}: {exc}[/red]")
elif auth_type == "token":
# Token-based connectors (e.g. Oura) — prompt for personal access token
import json
from pathlib import Path
from openjarvis.connectors.oauth import save_tokens
from openjarvis.core.config import DEFAULT_CONFIG_DIR
try:
instance = connector_cls()
if instance.is_connected():
console.print(f"[green]{source} is already connected.[/green]")
return
token = click.prompt(f"Enter your {source} personal access token")
token_dir = Path(DEFAULT_CONFIG_DIR) / "connectors"
token_dir.mkdir(parents=True, exist_ok=True)
token_file = token_dir / f"{source}.json"
token_file.write_text(json.dumps({"token": token}))
save_tokens(source, {"token": token})
console.print(f"[green]{source} connected successfully.[/green]")
except Exception as exc: # noqa: BLE001
console.print(f"[red]Token setup failed for {source}: {exc}[/red]")
else:
# Generic / bridge connectors
try:
@@ -154,8 +202,7 @@ def connect(
) -> None:
"""Manage data source connections (Gmail, Obsidian, etc.)."""
# Lazy imports to avoid top-level side effects
import openjarvis.connectors.gmail # noqa: F401
import openjarvis.connectors.obsidian # noqa: F401
import openjarvis.connectors # noqa: F401 — registers all connectors
from openjarvis.core.registry import ConnectorRegistry
if list_sources:
+264
View File
@@ -0,0 +1,264 @@
"""``jarvis digest`` — display and play the morning digest."""
from __future__ import annotations
import subprocess
import threading
from typing import Optional
import click
from rich.console import Console
from rich.markdown import Markdown
from openjarvis.agents.digest_store import DigestStore
from openjarvis.core.config import DEFAULT_CONFIG_PATH, load_config
def _play_audio(audio_path: str) -> None:
"""Play audio file in background using available system player."""
players = ["ffplay -nodisp -autoexit", "aplay", "afplay", "paplay"]
for player in players:
cmd_parts = player.split() + [audio_path]
try:
subprocess.run(
cmd_parts,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
return
except (FileNotFoundError, subprocess.CalledProcessError):
continue
def _save_digest_schedule(enabled: bool, cron: str) -> None:
"""Persist digest schedule to config.toml."""
config_path = DEFAULT_CONFIG_PATH
# Read existing TOML content (or start fresh)
content = ""
if config_path.exists():
content = config_path.read_text()
# Check if [digest] section already exists
lines = content.split("\n")
new_lines: list[str] = []
in_digest = False
digest_written = False
for line in lines:
stripped = line.strip()
# Detect start of [digest] section
if stripped == "[digest]":
in_digest = True
digest_written = True
new_lines.append("[digest]")
new_lines.append(f"enabled = {str(enabled).lower()}")
new_lines.append(f'schedule = "{cron}"')
continue
# If inside [digest], skip old enabled/schedule keys
if in_digest:
if stripped.startswith("[") and stripped != "[digest]":
in_digest = False
new_lines.append(line)
elif stripped.startswith("enabled") or stripped.startswith("schedule"):
continue
else:
new_lines.append(line)
else:
new_lines.append(line)
if not digest_written:
# Append [digest] section at the end
if new_lines and new_lines[-1].strip():
new_lines.append("")
new_lines.append("[digest]")
new_lines.append(f"enabled = {str(enabled).lower()}")
new_lines.append(f'schedule = "{cron}"')
new_lines.append("")
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text("\n".join(new_lines))
def _create_scheduler_task(cron: str) -> Optional[str]:
"""Create a digest task in the TaskScheduler. Returns task ID or None."""
try:
from openjarvis.scheduler.scheduler import TaskScheduler
from openjarvis.scheduler.store import SchedulerStore
db_path = DEFAULT_CONFIG_PATH.parent / "scheduler.db"
store = SchedulerStore(db_path)
scheduler = TaskScheduler(store)
# Cancel any existing digest tasks first
for task in scheduler.list_tasks(status="active"):
if task.agent == "morning_digest":
scheduler.cancel_task(task.id)
task = scheduler.create_task(
prompt="Generate my morning digest",
schedule_type="cron",
schedule_value=cron,
agent="morning_digest",
)
store.close()
return task.id
except Exception:
return None
def _cancel_scheduler_tasks() -> int:
"""Cancel all active digest tasks. Returns count cancelled."""
try:
from openjarvis.scheduler.scheduler import TaskScheduler
from openjarvis.scheduler.store import SchedulerStore
db_path = DEFAULT_CONFIG_PATH.parent / "scheduler.db"
store = SchedulerStore(db_path)
scheduler = TaskScheduler(store)
count = 0
for task in scheduler.list_tasks(status="active"):
if task.agent == "morning_digest":
scheduler.cancel_task(task.id)
count += 1
store.close()
return count
except Exception:
return 0
@click.command("digest", help="Display and play the morning digest.")
@click.option("--text-only", is_flag=True, help="Print text without audio playback.")
@click.option("--fresh", is_flag=True, help="Re-generate the digest (skip cache).")
@click.option("--history", is_flag=True, help="Show past digests.")
@click.option("--section", type=str, default="", help="Show only a specific section.")
@click.option("--db-path", type=str, default="", help="Path to digest database.")
@click.option(
"--schedule",
type=str,
default=None,
is_eager=True,
help=(
'Set cron schedule (e.g. "0 6 * * *"), '
'"off" to disable, or empty to show status.'
),
)
def digest(
text_only: bool,
fresh: bool,
history: bool,
section: str,
db_path: str,
schedule: Optional[str],
) -> None:
"""Display and optionally play the morning digest."""
console = Console()
# Handle --schedule flag
if schedule is not None:
_handle_schedule(console, schedule)
return
store = DigestStore(db_path=db_path) if db_path else DigestStore()
if history:
past = store.history(limit=10)
if not past:
console.print("[dim]No past digests found.[/dim]")
store.close()
return
for artifact in past:
console.print(
f"[bold]{artifact.generated_at.strftime('%Y-%m-%d %H:%M')}[/bold]"
f"{artifact.model_used} / {artifact.voice_used}"
)
console.print(artifact.text[:200] + "...\n")
store.close()
return
if fresh:
# Trigger on-demand generation
console.print("[yellow]Generating fresh digest...[/yellow]")
try:
from openjarvis.sdk import Jarvis
with Jarvis() as j:
result = j.ask("Generate my morning digest", agent="morning_digest")
console.print(Markdown(result))
except Exception as exc:
console.print(f"[red]Failed to generate digest: {exc}[/red]")
store.close()
return
# Try to load today's cached digest
artifact = store.get_today()
if artifact is None:
console.print("[dim]No digest for today. Use --fresh to generate one.[/dim]")
store.close()
return
# Display text
text = artifact.text
if section:
# Try to extract just the requested section
lines = text.split("\n")
in_section = False
section_lines = []
for line in lines:
if line.strip().lower().startswith(
f"## {section.lower()}"
) or line.strip().lower().startswith(f"# {section.lower()}"):
in_section = True
section_lines.append(line)
elif in_section and line.strip().startswith("#"):
break
elif in_section:
section_lines.append(line)
text = "\n".join(section_lines) if section_lines else text
# Play audio in background while text renders
audio_path = str(artifact.audio_path)
if not text_only and audio_path and artifact.audio_path.exists():
audio_thread = threading.Thread(
target=_play_audio, args=(audio_path,), daemon=True
)
audio_thread.start()
console.print(Markdown(text))
store.close()
def _handle_schedule(console: Console, schedule: str) -> None:
"""Handle the --schedule option logic."""
cfg = load_config()
digest_cfg = cfg.digest
if schedule == "":
# Show current schedule status
status = "enabled" if digest_cfg.enabled else "disabled"
console.print(f"[bold]Digest schedule:[/bold] {status}")
console.print(f" Cron: {digest_cfg.schedule}")
console.print(f" Timezone: {digest_cfg.timezone}")
return
if schedule.lower() == "off":
# Disable the schedule
_save_digest_schedule(enabled=False, cron=digest_cfg.schedule)
cancelled = _cancel_scheduler_tasks()
console.print("[yellow]Digest schedule disabled.[/yellow]")
if cancelled:
console.print(f" Cancelled {cancelled} scheduler task(s).")
return
# Set a new cron schedule
_save_digest_schedule(enabled=True, cron=schedule)
task_id = _create_scheduler_task(schedule)
console.print(f"[green]Digest schedule set:[/green] {schedule}")
if task_id:
console.print(f" Scheduler task created: {task_id}")
else:
console.print(
" [dim]Note: scheduler task not created "
"(start the scheduler separately).[/dim]"
)
+55 -2
View File
@@ -13,8 +13,11 @@ __all__ = ["Attachment", "BaseConnector", "Document", "KnowledgeStore", "SyncSta
# Auto-register built-in connectors
import openjarvis.connectors.obsidian # noqa: F401
# gmail (REST API / OAuth) is not registered — use gmail_imap instead.
# The REST API connector requires a full OAuth flow that isn't wired up yet.
try:
import openjarvis.connectors.gmail # noqa: F401
except ImportError:
pass
try:
import openjarvis.connectors.gmail_imap # noqa: F401
except ImportError:
@@ -50,6 +53,11 @@ try:
except ImportError:
pass
try:
import openjarvis.connectors.apple_music # noqa: F401
except ImportError:
pass
try:
import openjarvis.connectors.slack_connector # noqa: F401
except ImportError:
@@ -74,3 +82,48 @@ try:
import openjarvis.connectors.whatsapp # noqa: F401
except ImportError:
pass
try:
import openjarvis.connectors.oura # noqa: F401
except ImportError:
pass
try:
import openjarvis.connectors.apple_health # noqa: F401
except ImportError:
pass
try:
import openjarvis.connectors.strava # noqa: F401
except ImportError:
pass
try:
import openjarvis.connectors.spotify # noqa: F401
except ImportError:
pass
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
+419
View File
@@ -0,0 +1,419 @@
"""Apple Health connector -- reads HealthKit SQLite DB or iPhone Health export XML.
Two data sources are tried in order:
1. HealthKit SQLite DB at ``~/Library/Health/healthdb_secure.sqlite`` (macOS
with HealthKit sync enabled).
2. Health Export XML placed by the user at
``~/.openjarvis/connectors/apple_health_export/export.xml``.
Both are local-only; no API keys are needed.
"""
from __future__ import annotations
import hashlib
import json
import logging
import sqlite3
import xml.etree.ElementTree as ET
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.core.registry import ConnectorRegistry
logger = logging.getLogger(__name__)
_DEFAULT_EXPORT_PATH = str(
DEFAULT_CONFIG_DIR / "connectors" / "apple_health_export" / "export.xml"
)
_DEFAULT_HEALTHKIT_DB_PATH = str(
Path.home() / "Library" / "Health" / "healthdb_secure.sqlite"
)
# HK record type identifiers we care about
_STEP_COUNT = "HKQuantityTypeIdentifierStepCount"
_SLEEP_ANALYSIS = "HKCategoryTypeIdentifierSleepAnalysis"
_HEART_RATE = "HKQuantityTypeIdentifierHeartRate"
_RESTING_HEART_RATE = "HKQuantityTypeIdentifierRestingHeartRate"
_ACTIVE_ENERGY = "HKQuantityTypeIdentifierActiveEnergyBurned"
def _parse_health_date(date_str: str) -> datetime:
"""Parse date strings from Apple Health export XML.
Format is typically ``2024-03-15 08:00:00 -0700``.
"""
# Strip sub-second precision if present, then parse
try:
return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M:%S %z")
except ValueError:
# Fallback: try ISO-8601
return datetime.fromisoformat(date_str.strip())
def _day_key(dt: datetime) -> str:
"""Return ``YYYY-MM-DD`` for a datetime."""
return dt.strftime("%Y-%m-%d")
def _format_duration(seconds: float) -> str:
"""Return a human-readable duration like ``7h 23m``."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
if hours and minutes:
return f"{hours}h {minutes:02d}m"
if hours:
return f"{hours}h"
return f"{minutes}m"
@ConnectorRegistry.register("apple_health")
class AppleHealthConnector(BaseConnector):
"""Sync health data from Apple Health (local files only)."""
connector_id = "apple_health"
display_name = "Apple Health"
auth_type = "local"
def __init__(
self,
*,
export_path: str = _DEFAULT_EXPORT_PATH,
healthkit_db_path: str = _DEFAULT_HEALTHKIT_DB_PATH,
) -> None:
self._export_path = Path(export_path)
self._healthkit_db_path = Path(healthkit_db_path)
self._status = SyncStatus()
# ------------------------------------------------------------------
# BaseConnector interface
# ------------------------------------------------------------------
def is_connected(self) -> bool:
return self._healthkit_db_path.exists() or self._export_path.exists()
def disconnect(self) -> None:
# Local connector -- nothing to revoke.
pass
def sync(
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
) -> Iterator[Document]:
"""Yield health Documents, preferring HealthKit DB over export XML."""
self._status.state = "syncing"
try:
# Try HealthKit SQLite first
if self._healthkit_db_path.exists():
try:
yield from self._sync_healthkit_db(since=since)
self._status.state = "idle"
self._status.last_sync = datetime.now()
return
except Exception:
logger.debug(
"HealthKit DB not readable, falling back to export XML",
exc_info=True,
)
# Fall back to export XML
if self._export_path.exists():
yield from self._sync_export_xml(since=since)
self._status.state = "idle"
self._status.last_sync = datetime.now()
except Exception as exc:
self._status.state = "error"
self._status.error = str(exc)
raise
def sync_status(self) -> SyncStatus:
return self._status
# ------------------------------------------------------------------
# HealthKit SQLite DB
# ------------------------------------------------------------------
def _sync_healthkit_db(
self, *, since: Optional[datetime] = None
) -> Iterator[Document]:
"""Read health data from the local HealthKit SQLite database."""
conn = sqlite3.connect(str(self._healthkit_db_path))
conn.row_factory = sqlite3.Row
try:
# Query quantity_samples joined with samples for step count,
# heart rate, active energy. The schema uses Apple's CF
# absolute time (seconds since 2001-01-01).
cf_epoch = datetime(2001, 1, 1, tzinfo=timezone.utc)
since_cf = (
(since - cf_epoch).total_seconds()
if since and since.tzinfo
else (
(since.replace(tzinfo=timezone.utc) - cf_epoch).total_seconds()
if since
else 0
)
)
# Steps
steps_by_day: Dict[str, float] = defaultdict(float)
rows = conn.execute(
"""
SELECT s.start_date, qs.quantity
FROM samples s
JOIN quantity_samples qs ON s.data_id = qs.data_id
WHERE s.data_type = 7 AND s.start_date > ?
""",
(since_cf,),
).fetchall()
for row in rows:
dt = cf_epoch.replace(tzinfo=timezone.utc) + timedelta(
seconds=row["start_date"]
)
steps_by_day[_day_key(dt)] += row["quantity"]
for day, total in sorted(steps_by_day.items()):
yield Document(
doc_id=f"apple_health-steps-{day}",
source="apple_health",
doc_type="steps",
content=json.dumps({"date": day, "steps": int(total)}),
title=f"{int(total):,} steps",
timestamp=datetime.fromisoformat(day),
metadata={"data_type": "steps", "day": day},
)
finally:
conn.close()
# ------------------------------------------------------------------
# Export XML
# ------------------------------------------------------------------
def _sync_export_xml(
self, *, since: Optional[datetime] = None
) -> Iterator[Document]:
"""Stream-parse the Apple Health export XML and yield Documents."""
steps_by_day: Dict[str, float] = defaultdict(float)
sleep_by_day: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
hr_by_day: Dict[str, List[float]] = defaultdict(list)
energy_by_day: Dict[str, float] = defaultdict(float)
workouts: List[Dict[str, Any]] = []
for event, elem in ET.iterparse(str(self._export_path), events=("end",)):
tag = elem.tag
if tag == "Record":
rec_type = elem.get("type", "")
start_str = elem.get("startDate", "")
if not start_str:
elem.clear()
continue
try:
start_dt = _parse_health_date(start_str)
except (ValueError, TypeError):
elem.clear()
continue
if since and start_dt.replace(tzinfo=None) < since.replace(tzinfo=None):
elem.clear()
continue
day = _day_key(start_dt)
if rec_type == _STEP_COUNT:
val = float(elem.get("value", 0))
steps_by_day[day] += val
elif rec_type == _SLEEP_ANALYSIS:
end_str = elem.get("endDate", start_str)
try:
end_dt = _parse_health_date(end_str)
except (ValueError, TypeError):
end_dt = start_dt
sleep_by_day[day].append(
{
"start": start_str,
"end": end_str,
"value": elem.get("value", ""),
"duration_seconds": (end_dt - start_dt).total_seconds(),
}
)
elif rec_type in (_HEART_RATE, _RESTING_HEART_RATE):
try:
hr_by_day[day].append(float(elem.get("value", 0)))
except (ValueError, TypeError):
pass
elif rec_type == _ACTIVE_ENERGY:
try:
energy_by_day[day] += float(elem.get("value", 0))
except (ValueError, TypeError):
pass
elem.clear()
elif tag == "Workout":
start_str = elem.get("startDate", "")
if not start_str:
elem.clear()
continue
try:
start_dt = _parse_health_date(start_str)
except (ValueError, TypeError):
elem.clear()
continue
if since and start_dt.replace(tzinfo=None) < since.replace(tzinfo=None):
elem.clear()
continue
workouts.append(
{
"activity_type": elem.get("workoutActivityType", ""),
"duration_seconds": float(elem.get("duration", 0)),
"start": start_str,
"end": elem.get("endDate", ""),
"total_distance": elem.get("totalDistance", ""),
"total_energy_burned": elem.get("totalEnergyBurned", ""),
}
)
elem.clear()
else:
# Don't clear the root or other structural elements yet
pass
# Yield aggregated documents
yield from self._yield_step_docs(steps_by_day)
yield from self._yield_sleep_docs(sleep_by_day)
yield from self._yield_hr_docs(hr_by_day)
yield from self._yield_energy_docs(energy_by_day)
yield from self._yield_workout_docs(workouts)
# ------------------------------------------------------------------
# Document builders
# ------------------------------------------------------------------
@staticmethod
def _yield_step_docs(
steps_by_day: Dict[str, float],
) -> Iterator[Document]:
for day in sorted(steps_by_day):
total = int(steps_by_day[day])
yield Document(
doc_id=f"apple_health-steps-{day}",
source="apple_health",
doc_type="steps",
content=json.dumps({"date": day, "steps": total}),
title=f"{total:,} steps",
timestamp=datetime.fromisoformat(day),
metadata={"data_type": "steps", "day": day},
)
@staticmethod
def _yield_sleep_docs(
sleep_by_day: Dict[str, List[Dict[str, Any]]],
) -> Iterator[Document]:
for day in sorted(sleep_by_day):
entries = sleep_by_day[day]
total_seconds = sum(e["duration_seconds"] for e in entries)
yield Document(
doc_id=f"apple_health-sleep-{day}",
source="apple_health",
doc_type="sleep",
content=json.dumps(
{"date": day, "total_seconds": total_seconds, "entries": entries}
),
title=f"{_format_duration(total_seconds)} sleep",
timestamp=datetime.fromisoformat(day),
metadata={"data_type": "sleep", "day": day},
)
@staticmethod
def _yield_hr_docs(
hr_by_day: Dict[str, List[float]],
) -> Iterator[Document]:
for day in sorted(hr_by_day):
values = hr_by_day[day]
avg = round(sum(values) / len(values), 1) if values else 0
yield Document(
doc_id=f"apple_health-heart_rate-{day}",
source="apple_health",
doc_type="heart_rate",
content=json.dumps(
{
"date": day,
"avg_bpm": avg,
"min_bpm": min(values),
"max_bpm": max(values),
"samples": len(values),
}
),
title=f"Avg {avg} bpm heart rate",
timestamp=datetime.fromisoformat(day),
metadata={"data_type": "heart_rate", "day": day},
)
@staticmethod
def _yield_energy_docs(
energy_by_day: Dict[str, float],
) -> Iterator[Document]:
for day in sorted(energy_by_day):
total = round(energy_by_day[day], 1)
yield Document(
doc_id=f"apple_health-active_energy-{day}",
source="apple_health",
doc_type="active_energy",
content=json.dumps({"date": day, "kcal": total}),
title=f"{total} kcal active energy",
timestamp=datetime.fromisoformat(day),
metadata={"data_type": "active_energy", "day": day},
)
@staticmethod
def _yield_workout_docs(
workouts: List[Dict[str, Any]],
) -> Iterator[Document]:
for w in workouts:
# Friendly activity name
raw = w["activity_type"]
activity = (
raw.replace("HKWorkoutActivityType", "")
if raw.startswith("HKWorkoutActivityType")
else raw
)
# Build a descriptive title
parts = [activity]
if w.get("total_distance"):
try:
parts.append(f"{float(w['total_distance']):.1f} km")
except (ValueError, TypeError):
pass
duration_s = w.get("duration_seconds", 0)
if duration_s:
parts.append(_format_duration(float(duration_s)))
title = " -- ".join(parts) if len(parts) > 1 else activity
# Deterministic doc_id from workout content
digest = hashlib.sha256(json.dumps(w, sort_keys=True).encode()).hexdigest()[
:12
]
yield Document(
doc_id=f"apple_health-workout-{digest}",
source="apple_health",
doc_type="workout",
content=json.dumps(w),
title=title,
timestamp=datetime.fromisoformat(
_day_key(_parse_health_date(w["start"]))
),
metadata={"data_type": "workout", "activity": activity},
)
+242
View File
@@ -0,0 +1,242 @@
"""Apple Music connector -- reads tracks from the local Music.app via AppleScript.
Uses ``osascript`` subprocess calls to query Music.app on macOS. No API keys
or network access required; everything stays local.
Requires macOS (``sys.platform == "darwin"``) and the Music app to be
available.
"""
from __future__ import annotations
import hashlib
import json
import logging
import subprocess
import sys
from datetime import datetime
from typing import Iterator, Optional
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.core.registry import ConnectorRegistry
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# AppleScript helpers
# ---------------------------------------------------------------------------
_LIBRARY_CHECK_SCRIPT = 'tell application "Music" to get name of playlist "Library"'
_TRACKS_SCRIPT = """
tell application "Music"
set output to ""
set allTracks to every track of playlist "Library"
repeat with t in allTracks
set trackName to name of t
set trackArtist to artist of t
set trackAlbum to album of t
set trackDuration to duration of t
set trackGenre to genre of t
set trackPlayCount to played count of t
try
set pd to played date of t as string
on error
set pd to "never"
end try
set sep to "|||"
set row to trackName & sep & trackArtist & sep & trackAlbum
set row to row & sep & trackDuration & sep & trackGenre
set row to row & sep & trackPlayCount & sep & pd
set output to output & row & linefeed
end repeat
return output
end tell
"""
def _run_osascript(script: str, *, timeout: int = 120) -> Optional[str]:
"""Execute an AppleScript via ``osascript`` and return stdout.
Returns None on failure.
"""
try:
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode == 0:
return result.stdout.strip()
logger.debug("osascript failed (rc=%d): %s", result.returncode, result.stderr)
return None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
logger.debug("osascript error: %s", exc)
return None
def _track_doc_id(name: str, artist: str) -> str:
"""Deterministic doc_id from track name + artist."""
key = f"{name}:{artist}"
h = hashlib.sha256(key.encode("utf-8")).hexdigest()[:12]
return f"apple_music-{h}"
def _parse_played_date(raw: str) -> Optional[datetime]:
"""Try to parse an AppleScript date string; return None if unparseable."""
if not raw or raw.strip().lower() == "never":
return None
# AppleScript renders dates in the user's locale. Common macOS formats:
# "Saturday, March 15, 2026 at 2:30:00 PM"
# "2026-03-15 14:30:00"
for fmt in (
"%A, %B %d, %Y at %I:%M:%S %p",
"%A, %B %d, %Y at %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%m/%d/%Y %I:%M:%S %p",
"%d/%m/%Y %H:%M:%S",
):
try:
return datetime.strptime(raw.strip(), fmt)
except ValueError:
continue
logger.debug("Could not parse played_date: %r", raw)
return None
# ---------------------------------------------------------------------------
# AppleMusicConnector
# ---------------------------------------------------------------------------
@ConnectorRegistry.register("apple_music")
class AppleMusicConnector(BaseConnector):
"""Read tracks from the local Music.app library via AppleScript.
Only works on macOS where ``osascript`` is available and Music.app is
installed (ships with the OS).
"""
connector_id = "apple_music"
display_name = "Apple Music"
auth_type = "local"
def __init__(self) -> None:
self._status = SyncStatus()
# ------------------------------------------------------------------
# BaseConnector interface
# ------------------------------------------------------------------
def is_connected(self) -> bool:
"""Return True if running on macOS and Music.app responds."""
if sys.platform != "darwin":
return False
result = _run_osascript(_LIBRARY_CHECK_SCRIPT, timeout=10)
return result is not None
def disconnect(self) -> None:
"""No-op -- local connector with no credentials to revoke."""
def sync(
self,
*,
since: Optional[datetime] = None,
cursor: Optional[str] = None, # noqa: ARG002
) -> Iterator[Document]:
"""Query Music.app for all tracks and yield one Document per track.
Parameters
----------
since:
If provided, only yield tracks whose last-played date is after
this datetime.
cursor:
Not used for this local connector (included for API
compatibility).
Yields
------
Document
One document per track in the Music library.
"""
raw = _run_osascript(_TRACKS_SCRIPT)
if raw is None:
logger.warning("Could not retrieve tracks from Music.app")
self._status.state = "error"
self._status.error = "AppleScript query failed"
return
lines = [line for line in raw.split("\n") if line.strip()]
self._status.items_total = len(lines)
synced = 0
for line in lines:
parts = line.split("|||")
if len(parts) < 7:
logger.debug("Skipping malformed line: %r", line)
continue
name = parts[0].strip()
artist = parts[1].strip()
album = parts[2].strip()
duration_raw = parts[3].strip()
genre = parts[4].strip()
play_count_raw = parts[5].strip()
played_date_raw = parts[6].strip()
# Parse numeric fields
try:
duration_s = round(float(duration_raw), 2)
except (ValueError, TypeError):
duration_s = 0.0
try:
play_count = int(play_count_raw)
except (ValueError, TypeError):
play_count = 0
played_date = _parse_played_date(played_date_raw)
timestamp = played_date if played_date else datetime.now()
# Apply since filter
if since is not None and played_date is not None:
if played_date <= since:
continue
track_data = {
"name": name,
"artist": artist,
"album": album,
"duration_s": duration_s,
"genre": genre,
"play_count": play_count,
"played_date": played_date_raw,
}
doc = Document(
doc_id=_track_doc_id(name, artist),
source="apple_music",
doc_type="track",
content=json.dumps(track_data),
title=f"{name} \u2014 {artist}",
author=artist,
timestamp=timestamp,
metadata={
"album": album,
"duration_s": duration_s,
"genre": genre,
"play_count": play_count,
},
)
synced += 1
yield doc
self._status.items_synced = synced
self._status.state = "idle"
self._status.last_sync = datetime.now()
self._status.error = None
def sync_status(self) -> SyncStatus:
"""Return sync progress from the most recent :meth:`sync` call."""
return self._status
+20 -7
View File
@@ -7,16 +7,18 @@ to make them trivially mockable in tests.
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any, Dict, Iterator, List, Optional
import httpx
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.connectors.oauth import (
GOOGLE_ALL_SCOPES,
build_google_auth_url,
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
@@ -204,7 +206,9 @@ class GCalendarConnector(BaseConnector):
auth_type = "oauth"
def __init__(self, credentials_path: str = "") -> None:
self._credentials_path = credentials_path or _DEFAULT_CREDENTIALS_PATH
self._credentials_path = resolve_google_credentials(
credentials_path or _DEFAULT_CREDENTIALS_PATH
)
self._items_synced: int = 0
self._items_total: int = 0
self._last_sync: Optional[datetime] = None
@@ -236,7 +240,7 @@ class GCalendarConnector(BaseConnector):
return "https://console.cloud.google.com/apis/credentials"
return build_google_auth_url(
client_id=client_id,
scopes=[_GCAL_SCOPE],
scopes=GOOGLE_ALL_SCOPES,
)
def handle_callback(self, code: str) -> None:
@@ -265,7 +269,7 @@ class GCalendarConnector(BaseConnector):
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=[_GCAL_SCOPE],
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
@@ -279,7 +283,7 @@ class GCalendarConnector(BaseConnector):
def sync(
self,
*,
since: Optional[datetime] = None, # noqa: ARG002 — reserved for future use
since: Optional[datetime] = None,
cursor: Optional[str] = None,
) -> Iterator[Document]:
"""Yield :class:`Document` objects for Google Calendar events.
@@ -290,7 +294,8 @@ class GCalendarConnector(BaseConnector):
Parameters
----------
since:
Not yet used (filtering is done server-side via ``timeMin``).
Only return events starting after this datetime. Defaults to
24 hours ago when ``None``.
cursor:
``nextPageToken`` from a previous sync to resume pagination.
"""
@@ -306,6 +311,11 @@ class GCalendarConnector(BaseConnector):
calendars_resp = _gcal_api_calendars_list(token)
calendars: List[Dict[str, Any]] = calendars_resp.get("items", [])
# Default to 24 hours ago so we don't dump the entire calendar history
if since is None:
since = datetime.now() - timedelta(days=1)
time_min = since.strftime("%Y-%m-%dT%H:%M:%SZ")
synced = 0
for calendar in calendars:
@@ -318,7 +328,10 @@ class GCalendarConnector(BaseConnector):
while True:
try:
events_resp = _gcal_api_events_list(
token, calendar_id, page_token=page_token
token,
calendar_id,
page_token=page_token,
time_min=time_min,
)
except httpx.HTTPStatusError:
break
+7 -3
View File
@@ -14,9 +14,11 @@ import httpx
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.connectors.oauth import (
GOOGLE_ALL_SCOPES,
build_google_auth_url,
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
@@ -151,7 +153,9 @@ class GContactsConnector(BaseConnector):
auth_type = "oauth"
def __init__(self, credentials_path: str = "") -> None:
self._credentials_path = credentials_path or _DEFAULT_CREDENTIALS_PATH
self._credentials_path = resolve_google_credentials(
credentials_path or _DEFAULT_CREDENTIALS_PATH
)
self._items_synced: int = 0
self._items_total: int = 0
self._last_sync: Optional[datetime] = None
@@ -183,7 +187,7 @@ class GContactsConnector(BaseConnector):
return "https://console.cloud.google.com/apis/credentials"
return build_google_auth_url(
client_id=client_id,
scopes=[_GCONTACTS_SCOPE],
scopes=GOOGLE_ALL_SCOPES,
)
def handle_callback(self, code: str) -> None:
@@ -212,7 +216,7 @@ class GContactsConnector(BaseConnector):
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=[_GCONTACTS_SCOPE],
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
+7 -3
View File
@@ -14,9 +14,11 @@ import httpx
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.connectors.oauth import (
GOOGLE_ALL_SCOPES,
build_google_auth_url,
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
@@ -134,7 +136,9 @@ class GDriveConnector(BaseConnector):
auth_type = "oauth"
def __init__(self, credentials_path: str = "") -> None:
self._credentials_path = credentials_path or _DEFAULT_CREDENTIALS_PATH
self._credentials_path = resolve_google_credentials(
credentials_path or _DEFAULT_CREDENTIALS_PATH
)
self._items_synced: int = 0
self._items_total: int = 0
self._last_sync: Optional[datetime] = None
@@ -166,7 +170,7 @@ class GDriveConnector(BaseConnector):
return "https://console.cloud.google.com/apis/credentials"
return build_google_auth_url(
client_id=client_id,
scopes=[_GDRIVE_SCOPE],
scopes=GOOGLE_ALL_SCOPES,
)
def handle_callback(self, code: str) -> None:
@@ -197,7 +201,7 @@ class GDriveConnector(BaseConnector):
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=[_GDRIVE_SCOPE],
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
@@ -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
+8 -4
View File
@@ -16,9 +16,11 @@ import httpx
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.connectors.oauth import (
GOOGLE_ALL_SCOPES,
build_google_auth_url,
delete_tokens,
load_tokens,
resolve_google_credentials,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -184,7 +186,9 @@ class GmailConnector(BaseConnector):
auth_type = "oauth"
def __init__(self, credentials_path: str = "") -> None:
self._credentials_path = credentials_path or _DEFAULT_CREDENTIALS_PATH
self._credentials_path = resolve_google_credentials(
credentials_path or _DEFAULT_CREDENTIALS_PATH
)
self._items_synced: int = 0
self._items_total: int = 0
self._last_sync: Optional[datetime] = None
@@ -211,7 +215,7 @@ class GmailConnector(BaseConnector):
"""Return a Google OAuth consent URL requesting ``gmail.readonly`` scope."""
return build_google_auth_url(
client_id="", # placeholder — real client_id from config
scopes=[_GMAIL_SCOPE],
scopes=GOOGLE_ALL_SCOPES,
)
def handle_callback(self, code: str) -> None:
@@ -249,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
+116
View File
@@ -0,0 +1,116 @@
"""Google Tasks connector — tasks due today, overdue, and recently completed.
Uses OAuth2 tokens via the shared Google OAuth helper module.
"""
from __future__ import annotations
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.connectors.oauth import load_tokens, resolve_google_credentials
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.core.registry import ConnectorRegistry
_TASKS_API_BASE = "https://tasks.googleapis.com/tasks/v1"
_DEFAULT_CREDENTIALS_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "google_tasks.json")
def _tasks_api_get(
token: str, endpoint: str, params: Optional[Dict[str, str]] = None
) -> Dict[str, Any]:
"""Call a Google Tasks API v1 endpoint."""
resp = httpx.get(
f"{_TASKS_API_BASE}/{endpoint}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
@ConnectorRegistry.register("google_tasks")
class GoogleTasksConnector(BaseConnector):
"""Sync tasks from Google Tasks."""
connector_id = "google_tasks"
display_name = "Google Tasks"
auth_type = "oauth"
def __init__(self, *, credentials_path: str = "") -> None:
self._credentials_path = Path(
resolve_google_credentials(credentials_path or _DEFAULT_CREDENTIALS_PATH)
)
self._status = SyncStatus()
def _get_access_token(self) -> str:
tokens = load_tokens(str(self._credentials_path))
return tokens.get("access_token") or tokens.get("token", "")
def is_connected(self) -> bool:
return self._credentials_path.exists()
def disconnect(self) -> None:
if self._credentials_path.exists():
self._credentials_path.unlink()
def sync(
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
) -> Iterator[Document]:
token = self._get_access_token()
# List all task lists first
task_lists = _tasks_api_get(token, "users/@me/lists")
for tl in task_lists.get("items", []):
tl_id = tl["id"]
tl_title = tl.get("title", "My Tasks")
# Get tasks from this list, updated since cutoff
params: Dict[str, str] = {
"showCompleted": "true",
"showHidden": "false",
}
if since:
params["updatedMin"] = since.isoformat() + "Z"
tasks = _tasks_api_get(token, f"lists/{tl_id}/tasks", params=params)
for task in tasks.get("items", []):
due = task.get("due", "")
status = task.get("status", "needsAction")
ts = (
datetime.fromisoformat(
task.get("updated", "").replace("Z", "+00:00")
)
if task.get("updated")
else datetime.now()
)
yield Document(
doc_id=f"gtasks-{task['id']}",
source="google_tasks",
doc_type="task",
content=task.get("notes", ""),
title=task.get("title", "Untitled Task"),
timestamp=ts,
url=task.get("selfLink", ""),
metadata={
"task_list": tl_title,
"status": status,
"due": due,
"completed": task.get("completed", ""),
},
)
self._status.state = "idle"
self._status.last_sync = datetime.now()
def sync_status(self) -> SyncStatus:
return self._status
+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
+162
View File
@@ -0,0 +1,162 @@
"""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
+381 -15
View File
@@ -1,21 +1,181 @@
"""Shared OAuth 2.0 helpers for Google connectors.
"""Shared OAuth 2.0 helpers for all connectors.
Provides URL builder, token persistence, and token cleanup utilities
that are reused by gmail, drive, calendar, and contacts connectors.
Provides:
- ``OAuthProvider`` registry with configs for Google, Strava, Spotify
- Generic ``run_connector_oauth()`` that opens browser + catches callback
- URL builder, token persistence, and token cleanup utilities
"""
from __future__ import annotations
import base64
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
from openjarvis.core.config import DEFAULT_CONFIG_DIR
# ---------------------------------------------------------------------------
# Google OAuth endpoints / defaults
# Connector credentials directory
# ---------------------------------------------------------------------------
_CONNECTORS_DIR = DEFAULT_CONFIG_DIR / "connectors"
# ---------------------------------------------------------------------------
# OAuth provider registry
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class OAuthProvider:
"""Configuration for an OAuth 2.0 provider."""
name: str # "google", "strava", "spotify"
display_name: str
auth_endpoint: str
token_endpoint: str
scopes: List[str]
setup_url: str # URL where user creates OAuth credentials
setup_hint: str # One-line instruction for setup
callback_port: int = 8789
callback_host: str = "127.0.0.1"
callback_path: str = "/callback"
token_auth: str = "body" # "body" or "basic"
extra_auth_params: Dict[str, str] = field(default_factory=dict)
# Which connector IDs this provider covers (one flow → all connected)
connector_ids: Tuple[str, ...] = ()
# Filenames in ~/.openjarvis/connectors/ to save tokens to
credential_files: Tuple[str, ...] = ()
# Combined scopes for all Google connectors so a single OAuth consent
# authorises Drive, Calendar, Contacts, Gmail, and Tasks at once.
GOOGLE_ALL_SCOPES: List[str] = [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/tasks.readonly",
]
OAUTH_PROVIDERS: Dict[str, OAuthProvider] = {
"google": OAuthProvider(
name="google",
display_name="Google",
auth_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
token_endpoint="https://oauth2.googleapis.com/token",
scopes=GOOGLE_ALL_SCOPES,
setup_url="https://console.cloud.google.com/apis/credentials",
setup_hint="Create an OAuth 2.0 Client ID (Desktop app type)",
extra_auth_params={"access_type": "offline", "prompt": "consent"},
connector_ids=(
"gdrive",
"gcalendar",
"gcontacts",
"gmail",
"google_tasks",
),
credential_files=(
"google.json",
"gdrive.json",
"gcalendar.json",
"gcontacts.json",
"gmail.json",
"google_tasks.json",
),
),
"strava": OAuthProvider(
name="strava",
display_name="Strava",
auth_endpoint="https://www.strava.com/oauth/authorize",
token_endpoint="https://www.strava.com/oauth/token",
scopes=["activity:read_all"],
setup_url="https://www.strava.com/settings/api",
setup_hint="Create an API Application (callback domain: localhost)",
connector_ids=("strava",),
credential_files=("strava.json",),
),
"spotify": OAuthProvider(
name="spotify",
display_name="Spotify",
auth_endpoint="https://accounts.spotify.com/authorize",
token_endpoint="https://accounts.spotify.com/api/token",
scopes=["user-read-recently-played"],
setup_url="https://developer.spotify.com/dashboard",
setup_hint=("Create an app, add redirect URI: http://127.0.0.1:8888/callback"),
callback_port=8888,
token_auth="basic",
connector_ids=("spotify",),
credential_files=("spotify.json",),
),
}
def get_provider_for_connector(connector_id: str) -> Optional[OAuthProvider]:
"""Return the OAuthProvider that covers *connector_id*, or ``None``."""
for provider in OAUTH_PROVIDERS.values():
if connector_id in provider.connector_ids:
return provider
return None
# ---------------------------------------------------------------------------
# Credential helpers
# ---------------------------------------------------------------------------
def get_client_credentials(
provider: OAuthProvider,
) -> Optional[Tuple[str, str]]:
"""Load stored client_id and client_secret for *provider*.
Checks credential files in ``~/.openjarvis/connectors/`` and falls
back to environment variables ``OPENJARVIS_{NAME}_CLIENT_ID`` and
``OPENJARVIS_{NAME}_CLIENT_SECRET``.
"""
# Check credential files
for filename in provider.credential_files:
path = _CONNECTORS_DIR / filename
tokens = load_tokens(str(path))
if tokens and tokens.get("client_id") and tokens.get("client_secret"):
return tokens["client_id"], tokens["client_secret"]
# Check environment variables
prefix = f"OPENJARVIS_{provider.name.upper()}"
env_id = os.environ.get(f"{prefix}_CLIENT_ID", "")
env_secret = os.environ.get(f"{prefix}_CLIENT_SECRET", "")
if env_id and env_secret:
return env_id, env_secret
return None
def save_client_credentials(
provider: OAuthProvider,
client_id: str,
client_secret: str,
) -> None:
"""Persist client credentials so the user never has to enter them again."""
for filename in provider.credential_files:
path = _CONNECTORS_DIR / filename
existing = load_tokens(str(path)) or {}
existing["client_id"] = client_id
existing["client_secret"] = client_secret
save_tokens(str(path), existing)
# ---------------------------------------------------------------------------
# Shared credentials file — one OAuth flow covers all Google connectors
# ---------------------------------------------------------------------------
_SHARED_GOOGLE_CREDENTIALS_PATH: str = str(_CONNECTORS_DIR / "google.json")
_GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
_DEFAULT_REDIRECT_URI = "http://localhost:8789/callback"
_DEFAULT_SCOPES: List[str] = ["openid", "email", "profile"]
@@ -63,6 +223,20 @@ def build_google_auth_url(
return f"{_GOOGLE_AUTH_ENDPOINT}?{urlencode(params)}"
def resolve_google_credentials(connector_path: str) -> str:
"""Return the best available Google credentials file path.
Checks the connector-specific file first, then falls back to the
shared ``google.json``. Returns *connector_path* if neither exists
(so ``is_connected()`` correctly returns ``False``).
"""
if Path(connector_path).exists():
return connector_path
if Path(_SHARED_GOOGLE_CREDENTIALS_PATH).exists():
return _SHARED_GOOGLE_CREDENTIALS_PATH
return connector_path
def load_tokens(path: str) -> Optional[Dict[str, Any]]:
"""Load OAuth tokens from a JSON file.
@@ -281,16 +455,208 @@ def run_oauth_flow(
)
# Persist tokens together with client credentials (needed for refresh)
save_tokens(
credentials_path,
{
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_type": tokens.get("token_type", "Bearer"),
"expires_in": tokens.get("expires_in", 3600),
"client_id": client_id,
"client_secret": client_secret,
},
token_payload = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_type": tokens.get("token_type", "Bearer"),
"expires_in": tokens.get("expires_in", 3600),
"client_id": client_id,
"client_secret": client_secret,
}
save_tokens(credentials_path, token_payload)
# Also save to the shared Google credentials file so that all Google
# connectors can use this token without a separate OAuth flow.
if credentials_path != _SHARED_GOOGLE_CREDENTIALS_PATH:
save_tokens(_SHARED_GOOGLE_CREDENTIALS_PATH, token_payload)
return tokens
# ---------------------------------------------------------------------------
# Generic OAuth flow — works with any OAuthProvider
# ---------------------------------------------------------------------------
def _wait_for_callback_code(
host: str = "127.0.0.1",
port: int = 8789,
path: str = "/callback",
timeout: int = 120,
) -> str:
"""Start a localhost HTTP server and wait for ``?code=`` on *path*.
Returns the authorization code received from the OAuth redirect.
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
auth_code: List[str] = []
error: List[str] = []
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
params = parse_qs(urlparse(self.path).query)
if "code" in params:
auth_code.append(params["code"][0])
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body style='font-family:system-ui;text-align:center;"
b"padding:60px'>"
b"<h2 style='color:#22c55e'>Connected!</h2>"
b"<p>You can close this tab and return to OpenJarvis.</p>"
b"</body></html>"
)
elif "error" in params:
error.append(params.get("error", ["unknown"])[0])
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body style='font-family:system-ui;text-align:center;"
b"padding:60px'>"
b"<h2 style='color:#ef4444'>Authorization Failed</h2>"
b"<p>Please close this tab and try again.</p>"
b"</body></html>"
)
else:
self.send_response(400)
self.end_headers()
def log_message(self, *_args: Any) -> None:
pass
# Ensure port is free
import socket
test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
test_sock.bind((host, port))
except OSError:
pass
finally:
test_sock.close()
import time
time.sleep(0.3)
server = HTTPServer((host, port), _Handler)
server.timeout = timeout
while not auth_code and not error:
server.handle_request()
server.server_close()
if error:
raise RuntimeError(f"OAuth authorization denied: {error[0]}")
if not auth_code:
raise RuntimeError("OAuth callback timed out")
return auth_code[0]
def _exchange_token(
provider: OAuthProvider,
code: str,
client_id: str,
client_secret: str,
redirect_uri: str,
) -> Dict[str, Any]:
"""Exchange an authorization *code* for tokens using *provider* config."""
import httpx
data: Dict[str, str] = {
"code": code,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
}
headers: Dict[str, str] = {}
if provider.token_auth == "basic":
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
headers["Authorization"] = f"Basic {creds}"
else:
data["client_id"] = client_id
data["client_secret"] = client_secret
resp = httpx.post(provider.token_endpoint, data=data, headers=headers, timeout=30.0)
resp.raise_for_status()
return resp.json()
def run_connector_oauth(
connector_id: str,
client_id: str = "",
client_secret: str = "",
) -> Dict[str, Any]:
"""Run a complete OAuth flow for *connector_id*.
1. Look up the ``OAuthProvider``
2. Resolve client credentials (arg → stored → env)
3. Build auth URL and open the user's browser
4. Start localhost callback server and wait for the code
5. Exchange the code for tokens
6. Save tokens to all relevant credential files
Returns the raw token response dict.
"""
import webbrowser
provider = get_provider_for_connector(connector_id)
if provider is None:
raise ValueError(f"No OAuth provider configured for '{connector_id}'")
# Resolve credentials
if not (client_id and client_secret):
creds = get_client_credentials(provider)
if creds:
client_id, client_secret = creds
if not (client_id and client_secret):
raise RuntimeError(
f"No client credentials for {provider.display_name}. "
f"Set them up at: {provider.setup_url}"
)
redirect_uri = (
f"http://{provider.callback_host}:{provider.callback_port}"
f"{provider.callback_path}"
)
# Build auth URL
params: Dict[str, str] = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(provider.scopes),
**provider.extra_auth_params,
}
auth_url = f"{provider.auth_endpoint}?{urlencode(params)}"
# Open browser and wait for callback
webbrowser.open(auth_url)
code = _wait_for_callback_code(
host=provider.callback_host,
port=provider.callback_port,
path=provider.callback_path,
)
# Exchange code for tokens
tokens = _exchange_token(provider, code, client_id, client_secret, redirect_uri)
# Build payload with client credentials included (needed for refresh)
payload = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_type": tokens.get("token_type", "Bearer"),
"expires_in": tokens.get("expires_in", 3600),
"client_id": client_id,
"client_secret": client_secret,
}
# Save to all credential files for this provider
for filename in provider.credential_files:
save_tokens(str(_CONNECTORS_DIR / filename), payload)
return tokens
+90
View File
@@ -0,0 +1,90 @@
"""Oura Ring connector — sleep, readiness, and activity via REST API v2.
Uses a Personal Access Token (PAT) 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, timedelta
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
_OURA_API_BASE = "https://api.ouraring.com/v2/usercollection"
_DEFAULT_TOKEN_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "oura.json")
def _oura_api_get(
token: str, endpoint: str, params: Optional[Dict[str, str]] = None
) -> Dict[str, Any]:
"""Call an Oura API v2 endpoint."""
resp = httpx.get(
f"{_OURA_API_BASE}/{endpoint}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
@ConnectorRegistry.register("oura")
class OuraConnector(BaseConnector):
"""Sync sleep, readiness, and activity data from Oura Ring."""
connector_id = "oura"
display_name = "Oura Ring"
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 Oura 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 sleep, readiness, and activity."""
token = self._load_token()
start = (since or datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
end = datetime.now().strftime("%Y-%m-%d")
for data_type in ("sleep", "daily_readiness", "daily_activity"):
data = _oura_api_get(
token, data_type, params={"start_date": start, "end_date": end}
)
for item in data.get("data", []):
day = item.get("day", start)
yield Document(
doc_id=f"oura-{data_type}-{day}",
source="oura",
doc_type=data_type,
content=json.dumps(item),
title=f"Oura {data_type.replace('_', ' ').title()}{day}",
timestamp=datetime.fromisoformat(day),
metadata={"data_type": data_type, "day": day},
)
self._status.state = "idle"
self._status.last_sync = datetime.now()
def sync_status(self) -> SyncStatus:
return self._status
+3 -1
View File
@@ -302,7 +302,9 @@ class SlackConnector(BaseConnector):
# Try to join the public channel
try:
join_resp = _slack_api_with_retry(
"conversations.join", token, {"channel": chan_id},
"conversations.join",
token,
{"channel": chan_id},
http_method="POST",
)
if not join_resp.get("ok"):
+157
View File
@@ -0,0 +1,157 @@
"""Spotify connector — recently played tracks via Spotify Web API.
Uses OAuth2 tokens stored locally. Requires user-read-recently-played scope.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
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
_SPOTIFY_API_BASE = "https://api.spotify.com/v1"
_SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
_DEFAULT_TOKEN_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "spotify.json")
def _spotify_api_get(
token: str, endpoint: str, params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Call a Spotify Web API endpoint."""
resp = httpx.get(
f"{_SPOTIFY_API_BASE}/{endpoint}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
@ConnectorRegistry.register("spotify")
class SpotifyConnector(BaseConnector):
"""Sync recently played tracks from Spotify."""
connector_id = "spotify"
display_name = "Spotify"
auth_type = "oauth"
def __init__(self, *, token_path: str = _DEFAULT_TOKEN_PATH) -> None:
self._token_path = Path(token_path)
self._status = SyncStatus()
def _load_tokens(self) -> Dict[str, str]:
return json.loads(self._token_path.read_text(encoding="utf-8"))
def _get_access_token(self) -> str:
return self._load_tokens()["access_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 auth_url(self) -> str:
"""Return Spotify OAuth authorization URL."""
from urllib.parse import urlencode
from openjarvis.connectors.oauth import (
get_client_credentials,
get_provider_for_connector,
)
provider = get_provider_for_connector("spotify")
if not provider:
return "https://developer.spotify.com/dashboard"
creds = get_client_credentials(provider)
if not creds:
return "https://developer.spotify.com/dashboard"
client_id, _ = creds
redirect_uri = f"http://{provider.callback_host}:{provider.callback_port}{provider.callback_path}"
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(provider.scopes),
}
return f"{provider.auth_endpoint}?{urlencode(params)}"
def handle_callback(self, code: str) -> None:
"""Exchange authorization code for tokens and save."""
from openjarvis.connectors.oauth import (
_CONNECTORS_DIR,
_exchange_token,
get_client_credentials,
get_provider_for_connector,
save_tokens,
)
provider = get_provider_for_connector("spotify")
creds = get_client_credentials(provider) if provider else None
if not provider or not creds:
raise RuntimeError("Spotify client credentials not configured")
client_id, client_secret = creds
redirect_uri = f"http://{provider.callback_host}:{provider.callback_port}{provider.callback_path}"
tokens = _exchange_token(provider, code, client_id, client_secret, redirect_uri)
payload = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"client_id": client_id,
"client_secret": client_secret,
}
for filename in provider.credential_files:
save_tokens(str(_CONNECTORS_DIR / filename), payload)
def sync(
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
) -> Iterator[Document]:
token = self._get_access_token()
after_ms = int((since or datetime.now() - timedelta(days=1)).timestamp() * 1000)
data = _spotify_api_get(
token,
"me/player/recently-played",
params={"limit": "50", "after": str(after_ms)},
)
for item in data.get("items", []):
track = item.get("track", {})
played_at = item.get("played_at", "")
artists = ", ".join(a["name"] for a in track.get("artists", []))
ts = (
datetime.fromisoformat(played_at.replace("Z", "+00:00"))
if played_at
else datetime.now()
)
yield Document(
doc_id=f"spotify-{track.get('id', '')}-{played_at}",
source="spotify",
doc_type="recently_played",
content=json.dumps(item),
title=f"{track.get('name', 'Unknown')}{artists}",
author=artists,
timestamp=ts,
url=track.get("external_urls", {}).get("spotify", ""),
metadata={
"track_name": track.get("name", ""),
"album": track.get("album", {}).get("name", ""),
"duration_ms": track.get("duration_ms", 0),
},
)
self._status.state = "idle"
self._status.last_sync = datetime.now()
def sync_status(self) -> SyncStatus:
return self._status
+173
View File
@@ -0,0 +1,173 @@
"""Strava connector — recent activities via REST API v3.
Uses OAuth2 tokens stored locally. Refresh handled automatically.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
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
_STRAVA_API_BASE = "https://www.strava.com/api/v3"
_STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
_DEFAULT_TOKEN_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "strava.json")
def _strava_api_get(
token: str, endpoint: str, params: Optional[Dict[str, Any]] = None
) -> Any:
"""Call a Strava API v3 endpoint."""
resp = httpx.get(
f"{_STRAVA_API_BASE}/{endpoint}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
def _strava_refresh_token(
client_id: str, client_secret: str, refresh_token: str
) -> Dict[str, Any]:
"""Refresh an expired Strava OAuth2 token."""
resp = httpx.post(
_STRAVA_TOKEN_URL,
data={
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
@ConnectorRegistry.register("strava")
class StravaConnector(BaseConnector):
"""Sync recent activities from Strava."""
connector_id = "strava"
display_name = "Strava"
auth_type = "oauth"
def __init__(self, *, token_path: str = _DEFAULT_TOKEN_PATH) -> None:
self._token_path = Path(token_path)
self._status = SyncStatus()
def _load_tokens(self) -> Dict[str, str]:
return json.loads(self._token_path.read_text(encoding="utf-8"))
def _save_tokens(self, tokens: Dict[str, str]) -> None:
self._token_path.write_text(json.dumps(tokens), encoding="utf-8")
def _get_access_token(self) -> str:
tokens = self._load_tokens()
return tokens["access_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 auth_url(self) -> str:
"""Return Strava OAuth authorization URL."""
from urllib.parse import urlencode
from openjarvis.connectors.oauth import (
get_client_credentials,
get_provider_for_connector,
)
provider = get_provider_for_connector("strava")
if not provider:
return "https://www.strava.com/settings/api"
creds = get_client_credentials(provider)
if not creds:
return "https://www.strava.com/settings/api"
client_id, _ = creds
redirect_uri = f"http://{provider.callback_host}:{provider.callback_port}{provider.callback_path}"
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(provider.scopes),
}
return f"{provider.auth_endpoint}?{urlencode(params)}"
def handle_callback(self, code: str) -> None:
"""Exchange authorization code for tokens and save."""
from openjarvis.connectors.oauth import (
_CONNECTORS_DIR,
_exchange_token,
get_client_credentials,
get_provider_for_connector,
save_tokens,
)
provider = get_provider_for_connector("strava")
creds = get_client_credentials(provider) if provider else None
if not provider or not creds:
raise RuntimeError("Strava client credentials not configured")
client_id, client_secret = creds
redirect_uri = f"http://{provider.callback_host}:{provider.callback_port}{provider.callback_path}"
tokens = _exchange_token(provider, code, client_id, client_secret, redirect_uri)
payload = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"client_id": client_id,
"client_secret": client_secret,
}
for filename in provider.credential_files:
save_tokens(str(_CONNECTORS_DIR / filename), payload)
def sync(
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
) -> Iterator[Document]:
token = self._get_access_token()
after_epoch = int((since or datetime.now() - timedelta(days=7)).timestamp())
activities = _strava_api_get(
token,
"athlete/activities",
params={"after": str(after_epoch), "per_page": "50"},
)
for act in activities:
ts = (
datetime.fromisoformat(act["start_date_local"].replace("Z", "+00:00"))
if "start_date_local" in act
else datetime.now()
)
yield Document(
doc_id=f"strava-{act['id']}",
source="strava",
doc_type=act.get("type", "Activity").lower(),
content=json.dumps(act),
title=act.get("name", "Untitled Activity"),
timestamp=ts,
metadata={
"distance_m": act.get("distance", 0),
"moving_time_s": act.get("moving_time", 0),
"sport_type": act.get("sport_type", ""),
},
)
self._status.state = "idle"
self._status.last_sync = datetime.now()
def sync_status(self) -> SyncStatus:
return self._status
+131
View File
@@ -0,0 +1,131 @@
"""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
+46 -1
View File
@@ -14,7 +14,7 @@ import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
try:
import tomllib # Python 3.11+
@@ -1235,6 +1235,49 @@ class SkillsConfig:
auto_discover: bool = True
@dataclass
class DigestSectionConfig:
"""Configuration for a single digest section."""
sources: List[str] = field(default_factory=list)
max_items: int = 10
priority_contacts: List[str] = field(default_factory=list)
@dataclass
class DigestConfig:
"""Configuration for the morning digest feature."""
enabled: bool = False
schedule: str = "0 6 * * *"
timezone: str = "America/Los_Angeles"
persona: str = "jarvis"
sections: List[str] = field(
default_factory=lambda: ["messages", "calendar", "health", "world"]
)
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(
sources=["gmail", "slack", "google_tasks"]
)
)
calendar: DigestSectionConfig = field(
default_factory=lambda: DigestSectionConfig(sources=["gcalendar"])
)
health: DigestSectionConfig = field(
default_factory=lambda: DigestSectionConfig(sources=["oura", "apple_health"])
)
world: DigestSectionConfig = field(
default_factory=lambda: DigestSectionConfig(sources=[])
)
@dataclass
class JarvisConfig:
"""Top-level configuration for OpenJarvis."""
@@ -1263,6 +1306,7 @@ class JarvisConfig:
system_prompt: SystemPromptConfig = field(default_factory=SystemPromptConfig)
compression: CompressionConfig = field(default_factory=CompressionConfig)
skills: SkillsConfig = field(default_factory=SkillsConfig)
digest: DigestConfig = field(default_factory=DigestConfig)
@property
def memory(self) -> StorageConfig:
@@ -1472,6 +1516,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
"speech",
"optimize",
"agent_manager",
"digest",
)
for section_name in top_sections:
if section_name in data:
+5
View File
@@ -145,6 +145,10 @@ class CompressionRegistry(RegistryBase[Any]):
"""Registry for context compression strategies."""
class TTSRegistry(RegistryBase[Any]):
"""Registry for text-to-speech backend implementations."""
class ConnectorRegistry(RegistryBase[Any]):
"""Registry for data source connectors (Gmail, Slack, etc.)."""
@@ -163,5 +167,6 @@ __all__ = [
"RouterPolicyRegistry",
"SkillRegistry",
"SpeechRegistry",
"TTSRegistry",
"ToolRegistry",
]
+3
View File
@@ -326,6 +326,7 @@ def _build_dataset(benchmark: str, subset: str | None = None):
return PinchBenchDataset(path=subset)
elif benchmark == "taubench":
from openjarvis.evals.datasets.taubench import TauBenchDataset
domains = subset.split(",") if subset else None
return TauBenchDataset(domains=domains)
elif benchmark == "livecodebench":
@@ -478,6 +479,7 @@ def _build_scorer(benchmark: str, judge_backend, judge_model: str):
return PinchBenchScorer(judge_backend, judge_model)
elif benchmark == "taubench":
from openjarvis.evals.scorers.taubench import TauBenchScorer
return TauBenchScorer(judge_backend, judge_model)
elif benchmark == "livecodebench":
from openjarvis.evals.scorers.livecodebench import LiveCodeBenchScorer
@@ -597,6 +599,7 @@ def _run_terminalbench_native(config, console: Console) -> object:
)
import re
# Docker compose project names must be lowercase alphanumeric + hyphens/underscores
model_slug = re.sub(r"[^a-z0-9_-]", "-", model.lower().replace("/", "-"))
run_id = f"tb2-{model_slug}"
+3 -9
View File
@@ -361,9 +361,7 @@ class EvalRunner:
"completion_tokens", 0
)
if param_b > 0 and total_tokens > 0:
flops_per_tok = estimate_model_flops_per_token(
param_b, active_b
)
flops_per_tok = estimate_model_flops_per_token(param_b, active_b)
estimated_flops = flops_per_tok * total_tokens
# Extract derived and ITL metrics from _telemetry dict
@@ -1098,12 +1096,8 @@ def _summary_to_dict(s: RunSummary) -> Dict[str, Any]:
"energy_stats": _metric_stats_to_dict(s.energy_stats),
"power_stats": _metric_stats_to_dict(s.power_stats),
"flops_stats": _metric_stats_to_dict(s.flops_stats),
"ipw": (
s.efficiency.get("ipw") if s.efficiency else None
),
"ipj": (
s.efficiency.get("ipj") if s.efficiency else None
),
"ipw": (s.efficiency.get("ipw") if s.efficiency else None),
"ipj": (s.efficiency.get("ipj") if s.efficiency else None),
},
}
+7 -13
View File
@@ -163,25 +163,17 @@ class LiveCodeBenchDataset(DatasetProvider):
# Extract title
title = str(
raw.get("question_title")
or raw.get("title")
or raw.get("name")
or ""
raw.get("question_title") or raw.get("title") or raw.get("name") or ""
).strip()
# Extract difficulty
difficulty = str(
raw.get("difficulty")
or raw.get("question_difficulty")
or ""
raw.get("difficulty") or raw.get("question_difficulty") or ""
).strip()
# Extract platform/source
platform = str(
raw.get("platform")
or raw.get("source")
or raw.get("contest_source")
or ""
raw.get("platform") or raw.get("source") or raw.get("contest_source") or ""
).strip()
# Extract input/output format (may not always be separate fields)
@@ -266,8 +258,10 @@ class LiveCodeBenchDataset(DatasetProvider):
metadata[key] = val
# Build subject from platform and difficulty
subject = f"{platform}/{difficulty}" if platform and difficulty else (
platform or difficulty or "competitive-programming"
subject = (
f"{platform}/{difficulty}"
if platform and difficulty
else (platform or difficulty or "competitive-programming")
)
return EvalRecord(
+7 -2
View File
@@ -129,6 +129,7 @@ class TauBenchDataset(DatasetProvider):
# Load tasks, filtering to test split when available
from tau2.runner import load_task_splits
try:
task_splits = load_task_splits(domain)
test_ids = (
@@ -144,12 +145,14 @@ class TauBenchDataset(DatasetProvider):
tasks = [t for t in tasks if str(t.id) in test_ids]
LOGGER.info(
"TauBench: loaded %d test-split tasks for domain '%s'",
len(tasks), domain,
len(tasks),
domain,
)
else:
LOGGER.info(
"TauBench: loaded %d tasks for domain '%s' (no test split)",
len(tasks), domain,
len(tasks),
domain,
)
for task in tasks:
@@ -206,6 +209,7 @@ class TauBenchDataset(DatasetProvider):
if seed is not None:
import random
random.Random(seed).shuffle(all_records)
if max_samples is not None:
all_records = all_records[:max_samples]
@@ -226,6 +230,7 @@ class TauBenchDataset(DatasetProvider):
def create_task_env(self, record: EvalRecord):
"""Create a TauBench task environment for evaluation."""
from openjarvis.evals.execution.taubench_env import TauBenchTaskEnv
return TauBenchTaskEnv(
record,
engine_key=self._engine_key,
+179 -153
View File
@@ -310,18 +310,18 @@ SCENARIOS: List[Dict[str, Any]] = [
"category": "A-ToolSelection",
"user_message": "What's the weather like in Berlin right now?",
"mock_tool_outputs": {
"get_weather": json.dumps({
"location": "Berlin",
"temperature": 8,
"units": "celsius",
"condition": "Overcast",
"humidity": 72,
}),
"web_search": json.dumps({
"results": [
{"snippet": "Berlin weather right now: 8C and overcast."}
]
}),
"get_weather": json.dumps(
{
"location": "Berlin",
"temperature": 8,
"units": "celsius",
"condition": "Overcast",
"humidity": 72,
}
),
"web_search": json.dumps(
{"results": [{"snippet": "Berlin weather right now: 8C and overcast."}]}
),
},
},
{
@@ -330,18 +330,18 @@ SCENARIOS: List[Dict[str, Any]] = [
"category": "A-ToolSelection",
"user_message": "What is the current price of AAPL stock?",
"mock_tool_outputs": {
"get_stock_price": json.dumps({
"ticker": "AAPL",
"price": 187.42,
"currency": "USD",
"change": "+1.23",
"change_percent": "+0.66%",
}),
"web_search": json.dumps({
"results": [
{"snippet": "AAPL is trading around $187.42."}
]
}),
"get_stock_price": json.dumps(
{
"ticker": "AAPL",
"price": 187.42,
"currency": "USD",
"change": "+1.23",
"change_percent": "+0.66%",
}
),
"web_search": json.dumps(
{"results": [{"snippet": "AAPL is trading around $187.42."}]}
),
},
},
{
@@ -350,15 +350,15 @@ SCENARIOS: List[Dict[str, Any]] = [
"category": "A-ToolSelection",
"user_message": "I need to let Sarah know the meeting moved to 3pm.",
"mock_tool_outputs": {
"get_contacts": json.dumps({
"results": [
{"name": "Sarah Chen", "email": "sarah.chen@company.com"}
]
}),
"send_email": json.dumps({
"status": "sent",
"message_id": "msg_8821",
}),
"get_contacts": json.dumps(
{"results": [{"name": "Sarah Chen", "email": "sarah.chen@company.com"}]}
),
"send_email": json.dumps(
{
"status": "sent",
"message_id": "msg_8821",
}
),
},
},
# --- Category B: Parameter Precision ---
@@ -368,21 +368,25 @@ SCENARIOS: List[Dict[str, Any]] = [
"category": "B-ParameterPrecision",
"user_message": "What's the temperature in Tokyo in Fahrenheit?",
"mock_tool_outputs": {
"get_weather": json.dumps({
"location": "Tokyo",
"temperature": 64,
"units": "fahrenheit",
"condition": "Clear",
"humidity": 55,
}),
"get_weather": json.dumps(
{
"location": "Tokyo",
"temperature": 64,
"units": "fahrenheit",
"condition": "Clear",
"humidity": 55,
}
),
# Default (celsius) mock for partial-credit scoring
"get_weather__default": json.dumps({
"location": "Tokyo",
"temperature": 18,
"units": "celsius",
"condition": "Clear",
"humidity": 55,
}),
"get_weather__default": json.dumps(
{
"location": "Tokyo",
"temperature": 18,
"units": "celsius",
"condition": "Clear",
"humidity": 55,
}
),
},
},
{
@@ -394,18 +398,22 @@ SCENARIOS: List[Dict[str, Any]] = [
"30 minutes, with Alex and Jamie."
),
"mock_tool_outputs": {
"get_contacts": json.dumps({
"results": [
{"name": "Alex Stone", "email": "alex.stone@company.com"},
{"name": "Jamie Liu", "email": "jamie.liu@company.com"},
]
}),
"create_calendar_event": json.dumps({
"event_id": "evt_4412",
"status": "created",
"title": "Team Standup",
"date": "2026-03-23",
}),
"get_contacts": json.dumps(
{
"results": [
{"name": "Alex Stone", "email": "alex.stone@company.com"},
{"name": "Jamie Liu", "email": "jamie.liu@company.com"},
]
}
),
"create_calendar_event": json.dumps(
{
"event_id": "evt_4412",
"status": "created",
"title": "Team Standup",
"date": "2026-03-23",
}
),
},
# Reference date is 2026-03-20 (Friday), next Monday = 2026-03-23
"reference_date": "2026-03-20",
@@ -419,12 +427,16 @@ SCENARIOS: List[Dict[str, Any]] = [
"from English to both Spanish and Japanese."
),
"mock_tool_outputs": {
"translate_text__spanish": json.dumps({
"translated": "\u00bfD\u00f3nde est\u00e1 el hospital m\u00e1s cercano?",
}),
"translate_text__japanese": json.dumps({
"translated": "\u6700\u5bc4\u308a\u306e\u75c5\u9662\u306f\u3069\u3053\u3067\u3059\u304b\uff1f",
}),
"translate_text__spanish": json.dumps(
{
"translated": "\u00bfD\u00f3nde est\u00e1 el hospital m\u00e1s cercano?",
}
),
"translate_text__japanese": json.dumps(
{
"translated": "\u6700\u5bc4\u308a\u306e\u75c5\u9662\u306f\u3069\u3053\u3067\u3059\u304b\uff1f",
}
),
},
},
# --- Category C: Multi-Step Chains ---
@@ -436,29 +448,35 @@ SCENARIOS: List[Dict[str, Any]] = [
"Find the Q3 budget report and email the total to my manager."
),
"mock_tool_outputs": {
"search_files": json.dumps({
"results": [
{
"file_id": "file_091",
"name": "Q3_Budget_Report_2025.xlsx",
}
]
}),
"read_file": json.dumps({
"content": (
"Department budgets: Engineering $2.1M, Marketing $800K, "
"Sales $1.5M. Total: $4.4M"
),
}),
"get_contacts": json.dumps({
"results": [
{
"name": "Jordan Park",
"email": "jordan.park@company.com",
"role": "manager",
}
]
}),
"search_files": json.dumps(
{
"results": [
{
"file_id": "file_091",
"name": "Q3_Budget_Report_2025.xlsx",
}
]
}
),
"read_file": json.dumps(
{
"content": (
"Department budgets: Engineering $2.1M, Marketing $800K, "
"Sales $1.5M. Total: $4.4M"
),
}
),
"get_contacts": json.dumps(
{
"results": [
{
"name": "Jordan Park",
"email": "jordan.park@company.com",
"role": "manager",
}
]
}
),
"send_email": json.dumps({"status": "sent"}),
},
},
@@ -471,16 +489,20 @@ SCENARIOS: List[Dict[str, Any]] = [
"remind me to bring an umbrella tomorrow at 8am."
),
"mock_tool_outputs": {
"get_weather": json.dumps({
"location": "Paris",
"temperature": 11,
"condition": "Light rain",
"humidity": 89,
}),
"set_reminder": json.dumps({
"reminder_id": "rem_553",
"status": "set",
}),
"get_weather": json.dumps(
{
"location": "Paris",
"temperature": 11,
"condition": "Light rain",
"humidity": 89,
}
),
"set_reminder": json.dumps(
{
"reminder_id": "rem_553",
"status": "set",
}
),
},
"reference_date": "2026-03-20",
},
@@ -488,29 +510,33 @@ SCENARIOS: List[Dict[str, Any]] = [
"id": "TC-09",
"name": "Parallel Independence",
"category": "C-MultiStepChains",
"user_message": (
"What's the weather in London and the stock price of MSFT?"
),
"user_message": ("What's the weather in London and the stock price of MSFT?"),
"mock_tool_outputs": {
"get_weather": json.dumps({
"location": "London",
"temperature": 12,
"condition": "Cloudy",
}),
"get_stock_price": json.dumps({
"ticker": "MSFT",
"price": 412.78,
"currency": "USD",
}),
"web_search": json.dumps({
"results": [
{
"snippet": (
"London is cloudy at 12C and MSFT is around $412.78."
)
}
]
}),
"get_weather": json.dumps(
{
"location": "London",
"temperature": 12,
"condition": "Cloudy",
}
),
"get_stock_price": json.dumps(
{
"ticker": "MSFT",
"price": 412.78,
"currency": "USD",
}
),
"web_search": json.dumps(
{
"results": [
{
"snippet": (
"London is cloudy at 12C and MSFT is around $412.78."
)
}
]
}
),
},
},
# --- Category D: Restraint & Refusal ---
@@ -546,14 +572,16 @@ SCENARIOS: List[Dict[str, Any]] = [
"mock_tool_outputs": {
# First call returns empty, second (broader) returns a result
"search_files__first": json.dumps({"results": []}),
"search_files__retry": json.dumps({
"results": [
{
"file_id": "file_117",
"name": "Johnson_Project_Proposal_v2.docx",
}
]
}),
"search_files__retry": json.dumps(
{
"results": [
{
"file_id": "file_117",
"name": "Johnson_Project_Proposal_v2.docx",
}
]
}
),
},
},
{
@@ -562,14 +590,14 @@ SCENARIOS: List[Dict[str, Any]] = [
"category": "E-ErrorRecovery",
"user_message": "What's Apple's stock price?",
"mock_tool_outputs": {
"get_stock_price": json.dumps({
"error": "Service temporarily unavailable. Rate limit exceeded.",
}),
"web_search": json.dumps({
"results": [
{"snippet": "Apple (AAPL) is trading around $187.42."}
]
}),
"get_stock_price": json.dumps(
{
"error": "Service temporarily unavailable. Rate limit exceeded.",
}
),
"web_search": json.dumps(
{"results": [{"snippet": "Apple (AAPL) is trading around $187.42."}]}
),
},
},
{
@@ -577,20 +605,21 @@ SCENARIOS: List[Dict[str, Any]] = [
"name": "Conflicting Information",
"category": "E-ErrorRecovery",
"user_message": (
"Search for the population of Iceland "
"and calculate what 2% of it would be."
"Search for the population of Iceland and calculate what 2% of it would be."
),
"mock_tool_outputs": {
"web_search": json.dumps({
"results": [
{
"snippet": (
"Iceland has a population of approximately "
"372,520 as of 2025."
)
}
]
}),
"web_search": json.dumps(
{
"results": [
{
"snippet": (
"Iceland has a population of approximately "
"372,520 as of 2025."
)
}
]
}
),
"calculator": json.dumps({"result": 7450.4}),
},
},
@@ -629,9 +658,7 @@ class ToolCall15Dataset(DatasetProvider):
scenarios = [
s
for s in scenarios
if any(
s["category"].upper().startswith(fc) for fc in filter_cats
)
if any(s["category"].upper().startswith(fc) for fc in filter_cats)
]
if seed is not None:
@@ -645,8 +672,7 @@ class ToolCall15Dataset(DatasetProvider):
# instructions, available tools, and user message so the
# model can respond with tool calls via any backend.
tool_descriptions = "\n".join(
f"- {t['function']['name']}: "
f"{t['function']['description']}"
f"- {t['function']['name']}: {t['function']['description']}"
for t in TOOLS
)
prompt = (
+18 -9
View File
@@ -22,6 +22,7 @@ LOGGER = logging.getLogger(__name__)
# Message conversion helpers
# ---------------------------------------------------------------------------
def _tau2_to_oj_messages(
tau2_messages: list,
) -> list:
@@ -59,13 +60,12 @@ def _tau2_to_oj_messages(
)
)
else:
oj_msgs.append(
Message(role=Role.ASSISTANT, content=m.content or "")
)
oj_msgs.append(Message(role=Role.ASSISTANT, content=m.content or ""))
elif role_str == "tool":
# tau2 ToolMessage uses 'id', not 'tool_call_id'
raw_id = getattr(m, "id", "") or getattr(m, "tool_call_id", "") or ""
import re as _re
clean_id = _re.sub(r"[^a-zA-Z0-9_-]", "_", raw_id)
oj_msgs.append(
Message(
@@ -111,6 +111,7 @@ def _oj_result_to_tau2_msg(result: dict):
# Jarvis-powered tau2 agent
# ---------------------------------------------------------------------------
class JarvisHalfDuplexAgent:
"""A tau2 HalfDuplexAgent backed by OpenJarvis's inference engine.
@@ -139,6 +140,7 @@ class JarvisHalfDuplexAgent:
@property
def system_prompt(self) -> str:
from tau2.agent.llm_agent import AGENT_INSTRUCTION, SYSTEM_PROMPT
return SYSTEM_PROMPT.format(
domain_policy=self.domain_policy,
agent_instruction=AGENT_INSTRUCTION,
@@ -147,6 +149,7 @@ class JarvisHalfDuplexAgent:
def get_init_state(self, message_history=None):
from tau2.agent.llm_agent import LLMAgentState
from tau2.data_model.message import SystemMessage
return LLMAgentState(
system_messages=[
SystemMessage(role="system", content=self.system_prompt),
@@ -211,6 +214,7 @@ class JarvisHalfDuplexAgent:
# Task environment
# ---------------------------------------------------------------------------
class TauBenchTaskEnv:
"""Per-task environment for TauBench evaluation.
@@ -315,9 +319,7 @@ class TauBenchTaskEnv:
)
reward = (
simulation.reward_info.reward
if simulation.reward_info
else 0.0
simulation.reward_info.reward if simulation.reward_info else 0.0
)
info: dict = {}
if simulation.reward_info:
@@ -331,11 +333,16 @@ class TauBenchTaskEnv:
trial_label = (
f" (trial {trial + 1}/{self._num_trials})"
if self._num_trials > 1 else ""
if self._num_trials > 1
else ""
)
LOGGER.info(
"TauBench %s/%s: reward=%.2f messages=%d%s",
domain, task_id, reward, n_messages, trial_label,
domain,
task_id,
reward,
n_messages,
trial_label,
)
if reward > best_reward:
@@ -350,7 +357,9 @@ class TauBenchTaskEnv:
except Exception as exc:
LOGGER.error(
"TauBench simulation failed for %s/%s: %s",
domain, task_id, exc,
domain,
task_id,
exc,
)
self._record.metadata["tau_reward"] = best_reward
+22 -10
View File
@@ -43,11 +43,21 @@ def _extract_code(answer: str) -> str:
in_code = False
for line in lines:
stripped = line.lstrip()
if stripped.startswith((
"def ", "class ", "from ", "import ",
"n = ", "t = ", "for ", "while ",
"input(", "sys.stdin", "print(",
)):
if stripped.startswith(
(
"def ",
"class ",
"from ",
"import ",
"n = ",
"t = ",
"for ",
"while ",
"input(",
"sys.stdin",
"print(",
)
):
in_code = True
if in_code:
code_lines.append(line)
@@ -211,11 +221,13 @@ class LiveCodeBenchScorer(Scorer):
ok, detail = _run_single_test(code, inp, expected, timeout=timeout)
if ok:
passed += 1
test_details.append({
"test_index": i,
"passed": ok,
"detail": detail,
})
test_details.append(
{
"test_index": i,
"passed": ok,
"detail": detail,
}
)
if total == 0:
return None, {"reason": "no_test_cases_after_filtering"}
+1 -3
View File
@@ -277,9 +277,7 @@ class LiveResearchBenchScorer(LLMJudgeScorer):
try:
raw = self._ask_judge(prompt, temperature=0.0, max_tokens=4096)
except Exception as exc:
LOGGER.error(
"LLM judge call failed for %s: %s", record.record_id, exc
)
LOGGER.error("LLM judge call failed for %s: %s", record.record_id, exc)
return None, {"error": str(exc), "score": 0.0}
parsed = _parse_judge_response(raw)
+3 -1
View File
@@ -28,7 +28,9 @@ class TauBenchScorer(Scorer):
self._judge_model = judge_model
def score(
self, record: EvalRecord, model_answer: str,
self,
record: EvalRecord,
model_answer: str,
) -> Tuple[Optional[bool], Dict[str, Any]]:
reward = record.metadata.get("tau_reward", 0.0)
info = record.metadata.get("tau_info", {})
+88 -60
View File
@@ -41,20 +41,24 @@ def _extract_tool_calls(
for tc in getattr(turn, "tool_calls", []):
if tc is None:
continue
tool_calls.append({
"name": tc.get("name", ""),
"arguments": tc.get("arguments") or {},
})
tool_calls.append(
{
"name": tc.get("name", ""),
"arguments": tc.get("arguments") or {},
}
)
if tool_calls:
return tool_calls
# Try tool_results list (from JarvisAgentBackend)
tool_results = record.metadata.get("tool_results", [])
for tr in tool_results:
tool_calls.append({
"name": tr.get("tool_name", ""),
"arguments": tr.get("arguments") or {},
})
tool_calls.append(
{
"name": tr.get("tool_name", ""),
"arguments": tr.get("arguments") or {},
}
)
if tool_calls:
return tool_calls
@@ -133,7 +137,8 @@ def _arg_equals(tc: Dict[str, Any], key: str, value: str) -> bool:
def _score_tc01(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-01: Direct Specialist Match — get_weather for Berlin."""
weather = _tools_called(tool_calls, "get_weather")
@@ -151,7 +156,8 @@ def _score_tc01(
def _score_tc02(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-02: Distractor Resistance — get_stock_price for AAPL only."""
stock = _tools_called(tool_calls, "get_stock_price")
@@ -169,16 +175,15 @@ def _score_tc02(
def _score_tc03(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-03: Implicit Tool Need — get_contacts then send_email."""
contacts = _tools_called(tool_calls, "get_contacts")
email = _tools_called(tool_calls, "send_email")
if contacts and email:
contact_has_sarah = any(
_arg_contains(tc, "query", "sarah") for tc in contacts
)
contact_has_sarah = any(_arg_contains(tc, "query", "sarah") for tc in contacts)
email_has_addr = any(
_arg_contains(tc, "to", "sarah.chen@company.com") for tc in email
)
@@ -197,7 +202,8 @@ def _score_tc03(
def _score_tc04(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-04: Unit Handling — get_weather(Tokyo, units=fahrenheit)."""
weather = _tools_called(tool_calls, "get_weather")
@@ -219,7 +225,8 @@ def _score_tc04(
def _score_tc05(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-05: Date and Time Parsing — create_calendar_event with correct fields."""
events = _tools_called(tool_calls, "create_calendar_event")
@@ -239,7 +246,9 @@ def _score_tc05(
time_ok = time_val in ("09:30", "9:30")
duration_ok = duration == 30 or str(duration) == "30"
attendees_lower = [a.lower() if isinstance(a, str) else "" for a in (attendees or [])]
attendees_lower = [
a.lower() if isinstance(a, str) else "" for a in (attendees or [])
]
attendees_str = " ".join(attendees_lower)
has_alex = "alex" in attendees_str
has_jamie = "jamie" in attendees_str
@@ -252,7 +261,8 @@ def _score_tc05(
def _score_tc06(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-06: Multi-Value Extraction — two translate_text calls."""
translates = _tools_called(tool_calls, "translate_text")
@@ -278,7 +288,8 @@ def _score_tc06(
def _score_tc07(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-07: Search -> Read -> Act — 4-step chain."""
search = _tools_called(tool_calls, "search_files")
@@ -286,24 +297,20 @@ def _score_tc07(
contacts = _tools_called(tool_calls, "get_contacts")
email = _tools_called(tool_calls, "send_email")
steps_done = sum([
bool(search),
bool(read),
bool(contacts),
bool(email),
])
steps_done = sum(
[
bool(search),
bool(read),
bool(contacts),
bool(email),
]
)
if steps_done == 4:
# Verify data threading
has_file_id = any(
_arg_contains(tc, "file_id", "file_091") for tc in read
)
has_manager = any(
_arg_contains(tc, "query", "manager") for tc in contacts
)
has_total = any(
_arg_contains(tc, "body", "4.4") for tc in email
)
has_file_id = any(_arg_contains(tc, "file_id", "file_091") for tc in read)
has_manager = any(_arg_contains(tc, "query", "manager") for tc in contacts)
has_total = any(_arg_contains(tc, "body", "4.4") for tc in email)
email_to = any(
_arg_contains(tc, "to", "jordan.park@company.com") for tc in email
)
@@ -317,16 +324,15 @@ def _score_tc07(
def _score_tc08(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-08: Conditional Branching — weather check then conditional reminder."""
weather = _tools_called(tool_calls, "get_weather")
reminder = _tools_called(tool_calls, "set_reminder")
if weather and reminder:
weather_paris = any(
_arg_contains(tc, "location", "paris") for tc in weather
)
weather_paris = any(_arg_contains(tc, "location", "paris") for tc in weather)
reminder_umbrella = any(
_arg_contains(tc, "message", "umbrella") for tc in reminder
)
@@ -345,19 +351,16 @@ def _score_tc08(
def _score_tc09(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-09: Parallel Independence — weather AND stock price."""
weather = _tools_called(tool_calls, "get_weather")
stock = _tools_called(tool_calls, "get_stock_price")
web = _tools_called(tool_calls, "web_search")
weather_london = any(
_arg_contains(tc, "location", "london") for tc in weather
)
stock_msft = any(
_arg_contains(tc, "ticker", "msft") for tc in stock
)
weather_london = any(_arg_contains(tc, "location", "london") for tc in weather)
stock_msft = any(_arg_contains(tc, "ticker", "msft") for tc in stock)
if weather_london and stock_msft:
return 2, "PASS: both get_weather(London) and get_stock_price(MSFT)"
@@ -370,7 +373,8 @@ def _score_tc09(
def _score_tc10(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-10: Trivial Knowledge — answer without tools."""
if not tool_calls and "1945" in answer:
@@ -381,7 +385,8 @@ def _score_tc10(
def _score_tc11(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-11: Simple Math — answer without tools."""
if not tool_calls and "30" in answer:
@@ -400,14 +405,22 @@ def _score_tc11(
def _score_tc12(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-12: Impossible Request — refuse without tool calls."""
lower = answer.lower()
refusal_phrases = [
"cannot", "can't", "do not have", "don't have",
"not able", "unable", "no tool", "not available",
"not possible", "no way to",
"cannot",
"can't",
"do not have",
"don't have",
"not able",
"unable",
"no tool",
"not available",
"not possible",
"no way to",
]
has_refusal = any(phrase in lower for phrase in refusal_phrases)
mentions_delete = "delete" in lower or "available tool" in lower or "tool" in lower
@@ -422,7 +435,8 @@ def _score_tc12(
def _score_tc13(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-13: Empty Results — retry or ask for clarification."""
search = _tools_called(tool_calls, "search_files")
@@ -432,8 +446,15 @@ def _score_tc13(
if len(search) == 1:
lower = answer.lower()
clarification_phrases = [
"could you", "can you", "more details", "more specific",
"clarify", "which", "what type", "not found", "no results",
"could you",
"can you",
"more details",
"more specific",
"clarify",
"which",
"what type",
"not found",
"no results",
]
if any(phrase in lower for phrase in clarification_phrases):
return 2, "PASS: single search with clarification request"
@@ -441,7 +462,8 @@ def _score_tc13(
def _score_tc14(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-14: Malformed Response — surface error, offer fallback."""
stock = _tools_called(tool_calls, "get_stock_price")
@@ -449,8 +471,13 @@ def _score_tc14(
lower = answer.lower()
error_ack_phrases = [
"temporarily unavailable", "rate limit", "service",
"couldn't", "error", "unable", "failed",
"temporarily unavailable",
"rate limit",
"service",
"couldn't",
"error",
"unable",
"failed",
"get_stock_price",
]
has_error_ack = any(phrase in lower for phrase in error_ack_phrases)
@@ -466,15 +493,16 @@ def _score_tc14(
def _score_tc15(
tool_calls: List[Dict[str, Any]], answer: str,
tool_calls: List[Dict[str, Any]],
answer: str,
) -> Tuple[int, str]:
"""TC-15: Conflicting Information — use search result in calculator."""
web = _tools_called(tool_calls, "web_search")
calc = _tools_called(tool_calls, "calculator")
web_has_iceland = any(
_arg_contains(tc, "query", "iceland") or
_arg_contains(tc, "query", "population")
_arg_contains(tc, "query", "iceland")
or _arg_contains(tc, "query", "population")
for tc in web
)
+28
View File
@@ -482,6 +482,34 @@ class Jarvis:
if self._capability_policy is not None:
agent_kwargs["capability_policy"] = self._capability_policy
# Inject DigestConfig for morning_digest agent
if agent_name == "morning_digest" and hasattr(self._config, "digest"):
dc = self._config.digest
section_sources: Dict[str, Any] = {}
for s in dc.sections:
sc = getattr(dc, s, None)
if sc and hasattr(sc, "sources"):
section_sources[s] = sc.sources
agent_kwargs.update(
{
"persona": dc.persona,
"sections": dc.sections,
"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
from openjarvis.tools.text_to_speech import TextToSpeechTool
digest_tools = [DigestCollectTool(), TextToSpeechTool()]
existing = agent_kwargs.get("tools", [])
agent_kwargs["tools"] = digest_tools + list(existing)
agent_obj = agent_cls(self._engine, model_name, **agent_kwargs)
ctx = AgentContext()
+70 -71
View File
@@ -112,7 +112,8 @@ def _make_lightweight_system(
)
plain_engine = InstrumentedEngine(
plain_engine, get_event_bus(),
plain_engine,
get_event_bus(),
)
except Exception:
pass # telemetry is optional
@@ -559,7 +560,8 @@ async def _stream_managed_agent(
agent_type = agent_record.get("agent_type", "")
if agent_type == "deep_research":
dr_tools = _build_deep_research_tools(
engine=engine, model=model,
engine=engine,
model=model,
)
# Store on app_state so streaming loop can access them
if app_state is not None and dr_tools:
@@ -628,9 +630,7 @@ async def _stream_managed_agent(
def _tracked_execute(tc):
tool_name = tc.name
args_str = (
tc.arguments[:80] if tc.arguments else ""
)
args_str = tc.arguments[:80] if tc.arguments else ""
# Log tool call start
try:
manager.add_learning_log(
@@ -642,11 +642,13 @@ async def _stream_managed_agent(
except Exception as _tc_exc:
logger.warning("Log tool_call failed: %s", _tc_exc)
progress_q.put({
"type": "tool_start",
"tool": tool_name,
"args": args_str,
})
progress_q.put(
{
"type": "tool_start",
"tool": tool_name,
"args": args_str,
}
)
result = original_execute(tc)
# Log tool result
@@ -666,11 +668,13 @@ async def _stream_managed_agent(
except Exception as _tr_exc:
logger.warning("Log tool_result failed: %s", _tr_exc)
progress_q.put({
"type": "tool_end",
"tool": tool_name,
"success": result.success,
})
progress_q.put(
{
"type": "tool_end",
"tool": tool_name,
"success": result.success,
}
)
return result
dr_agent._executor.execute = _tracked_execute
@@ -679,9 +683,7 @@ async def _stream_managed_agent(
agent_metadata = {}
try:
result = dr_agent.run(user_content)
content = (
result.content or "No results found."
)
content = result.content or "No results found."
agent_metadata = result.metadata or {}
except Exception as exc:
content = f"Error: {exc}"
@@ -703,15 +705,18 @@ async def _stream_managed_agent(
)
except Exception as _qc_exc:
logger.warning(
"Log failed: %s", _qc_exc,
"Log failed: %s",
_qc_exc,
)
progress_q.put({
"type": "error" if content.startswith("Error:") else "done",
"content": content,
"metadata": agent_metadata,
"elapsed": elapsed,
})
progress_q.put(
{
"type": "error" if content.startswith("Error:") else "done",
"content": content,
"metadata": agent_metadata,
"elapsed": elapsed,
}
)
thread = threading.Thread(target=_run_agent, daemon=True)
thread.start()
@@ -795,7 +800,8 @@ async def _stream_managed_agent(
# Persist
manager.store_agent_response(
agent_id, content,
agent_id,
content,
)
break
@@ -1235,7 +1241,9 @@ def create_agent_manager_router(
@agents_router.post("/{agent_id}/channels")
async def bind_channel(
agent_id: str, req: BindChannelRequest, request: Request,
agent_id: str,
req: BindChannelRequest,
request: Request,
):
if not manager.get_agent(agent_id):
raise HTTPException(status_code=404, detail="Agent not found")
@@ -1315,9 +1323,7 @@ def create_agent_manager_router(
request.app.state.sendblue_channel = sb_channel
# Create or update the channel bridge
bridge = getattr(
request.app.state, "channel_bridge", None
)
bridge = getattr(request.app.state, "channel_bridge", None)
if bridge and hasattr(bridge, "_channels"):
bridge._channels["sendblue"] = sb_channel
else:
@@ -1330,39 +1336,30 @@ def create_agent_manager_router(
)
session_store = SessionStore()
engine = getattr(
request.app.state, "engine", None
)
engine = getattr(request.app.state, "engine", None)
dr_agent = None
if engine:
from openjarvis.server.agent_manager_routes import (
_build_deep_research_tools as _bdr,
)
tools = _bdr(
engine=engine, model=""
)
tools = _bdr(engine=engine, model="")
if tools:
from openjarvis.agents.deep_research import (
DeepResearchAgent,
)
model_name = (
getattr(engine, "_model", "")
or getattr(
request.app.state,
"model",
"",
)
model_name = getattr(engine, "_model", "") or getattr(
request.app.state,
"model",
"",
)
dr_agent = DeepResearchAgent(
engine=engine,
model=model_name,
tools=tools,
)
bus = getattr(
request.app.state, "bus", None
)
bus = getattr(request.app.state, "bus", None)
if bus is None:
from openjarvis.core.events import EventBus
@@ -1381,9 +1378,7 @@ def create_agent_manager_router(
from_number,
)
except Exception as exc:
logger.warning(
"Failed to init SendBlue channel: %s", exc
)
logger.warning("Failed to init SendBlue channel: %s", exc)
# Start Slack via slack-bolt Socket Mode
if req.channel_type == "slack":
@@ -1403,31 +1398,40 @@ def create_agent_manager_router(
stop_slack()
# Spawn as subprocess (reliable)
srv_model = getattr(
srv_model = (
getattr(
request.app.state, "engine", None,
),
"_model",
"qwen3.5:9b",
) or "qwen3.5:9b"
getattr(
request.app.state,
"engine",
None,
),
"_model",
"qwen3.5:9b",
)
or "qwen3.5:9b"
)
pid = start_slack_daemon(
bot_token=bot_token,
app_token=app_token,
model=srv_model,
)
logger.info(
"Slack daemon started (PID %d)", pid,
"Slack daemon started (PID %d)",
pid,
)
except Exception as exc:
logger.warning(
"Failed to start Slack: %s", exc,
"Failed to start Slack: %s",
exc,
)
return binding
@agents_router.delete("/{agent_id}/channels/{binding_id}")
async def unbind_channel(
agent_id: str, binding_id: str, request: Request,
agent_id: str,
binding_id: str,
request: Request,
):
try:
binding = manager._get_binding(binding_id)
@@ -1747,9 +1751,7 @@ def create_agent_manager_router(
# ── SendBlue auto-setup helpers ─────────────────────────
sendblue_router = APIRouter(
prefix="/v1/channels/sendblue", tags=["sendblue"]
)
sendblue_router = APIRouter(prefix="/v1/channels/sendblue", tags=["sendblue"])
@sendblue_router.post("/verify")
async def sendblue_verify(request: Request):
@@ -1786,8 +1788,10 @@ def create_agent_manager_router(
)
data = resp.json()
# data might be a list of lines or {"lines": [...]}
lines = data if isinstance(data, list) else data.get(
"lines", data.get("data", [])
lines = (
data
if isinstance(data, list)
else data.get("lines", data.get("data", []))
)
numbers = []
for line in lines:
@@ -1843,9 +1847,7 @@ def create_agent_manager_router(
return {
"registered": resp.status_code < 300,
"status": resp.status_code,
"response": resp.json()
if resp.status_code < 300
else resp.text[:200],
"response": resp.json() if resp.status_code < 300 else resp.text[:200],
}
except Exception as exc:
raise HTTPException(
@@ -1894,9 +1896,7 @@ def create_agent_manager_router(
return {
"sent": resp.status_code < 300,
"status": resp.status_code,
"response": resp.json()
if resp.status_code < 300
else resp.text[:200],
"response": resp.json() if resp.status_code < 300 else resp.text[:200],
}
except Exception as exc:
raise HTTPException(
@@ -1910,8 +1910,7 @@ def create_agent_manager_router(
sb = getattr(request.app.state, "sendblue_channel", None)
bridge = getattr(request.app.state, "channel_bridge", None)
has_bridge = bridge is not None and (
hasattr(bridge, "_channels")
and "sendblue" in bridge._channels
hasattr(bridge, "_channels") and "sendblue" in bridge._channels
)
return {
"channel_connected": sb is not None,
+7 -6
View File
@@ -14,6 +14,7 @@ from openjarvis.server.api_routes import include_all_routes
from openjarvis.server.comparison import comparison_router
from openjarvis.server.connectors_router import create_connectors_router
from openjarvis.server.dashboard import dashboard_router
from openjarvis.server.digest_routes import create_digest_router
from openjarvis.server.routes import router
from openjarvis.server.upload_router import router as upload_router
@@ -72,17 +73,15 @@ def _restore_sendblue_bindings(app: FastAPI) -> None:
_build_deep_research_tools,
)
tools = _build_deep_research_tools(
engine=engine, model=""
)
tools = _build_deep_research_tools(engine=engine, model="")
if tools:
from openjarvis.agents.deep_research import (
DeepResearchAgent,
)
model_name = getattr(
app.state, "model", ""
) or getattr(engine, "_model", "")
model_name = getattr(app.state, "model", "") or getattr(
engine, "_model", ""
)
dr_agent = DeepResearchAgent(
engine=engine,
model=model_name,
@@ -111,6 +110,7 @@ def _restore_sendblue_bindings(app: FastAPI) -> None:
except Exception as exc:
logger.debug("SendBlue binding restore skipped: %s", exc)
# No-cache headers applied to static file responses
_NO_CACHE_HEADERS = {
"Cache-Control": "no-cache, no-store, must-revalidate",
@@ -223,6 +223,7 @@ def create_app(
app.include_router(dashboard_router)
app.include_router(comparison_router)
app.include_router(create_connectors_router())
app.include_router(create_digest_router())
app.include_router(upload_router)
include_all_routes(app)
+159 -1
View File
@@ -78,7 +78,7 @@ def create_connectors_router():
this package.
"""
try:
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
except ImportError as exc:
raise ImportError(
"fastapi and pydantic are required for the connectors router"
@@ -175,6 +175,26 @@ def create_connectors_router():
except Exception:
pass
# Include OAuth provider setup info if applicable
oauth_setup = None
try:
from openjarvis.connectors.oauth import (
get_client_credentials,
get_provider_for_connector,
)
provider = get_provider_for_connector(connector_id)
if provider:
has_creds = get_client_credentials(provider) is not None
oauth_setup = {
"provider": provider.name,
"setup_url": provider.setup_url,
"setup_hint": provider.setup_hint,
"has_credentials": has_creds,
}
except Exception:
pass
return {
"connector_id": connector_id,
"display_name": getattr(instance, "display_name", connector_id),
@@ -182,6 +202,7 @@ def create_connectors_router():
"connected": instance.is_connected(),
"auth_url": auth_url,
"mcp_tools": mcp_tools,
"oauth_setup": oauth_setup,
}
@router.post("/{connector_id}/connect")
@@ -282,6 +303,143 @@ def create_connectors_router():
"status": "disconnected",
}
@router.get("/{connector_id}/oauth/start")
async def oauth_start(connector_id: str, request: Request):
"""Redirect to the OAuth provider's consent page.
The callback will come back to /v1/connectors/{id}/oauth/callback.
"""
from urllib.parse import urlencode
from openjarvis.connectors.oauth import (
get_client_credentials,
get_provider_for_connector,
)
_ensure_connectors_registered()
if not ConnectorRegistry.contains(connector_id):
raise HTTPException(404, f"Connector '{connector_id}' not found")
provider = get_provider_for_connector(connector_id)
if not provider:
raise HTTPException(400, f"No OAuth provider for '{connector_id}'")
creds = get_client_credentials(provider)
if not creds:
raise HTTPException(
400,
f"No client credentials configured for {provider.display_name}. "
f"Set up at: {provider.setup_url}",
)
client_id, _ = creds
# Build callback URL pointing to our own server
base_url = str(request.base_url).rstrip("/")
callback_url = f"{base_url}/v1/connectors/{connector_id}/oauth/callback"
params = {
"client_id": client_id,
"redirect_uri": callback_url,
"response_type": "code",
"scope": " ".join(provider.scopes),
**provider.extra_auth_params,
}
auth_url = f"{provider.auth_endpoint}?{urlencode(params)}"
from fastapi.responses import RedirectResponse
return RedirectResponse(url=auth_url)
@router.get("/{connector_id}/oauth/callback")
async def oauth_callback(
connector_id: str,
code: str = "",
error: str = "",
request: Request = None,
):
"""Handle OAuth callback from the provider."""
from fastapi.responses import HTMLResponse
from openjarvis.connectors.oauth import (
_CONNECTORS_DIR,
_exchange_token,
get_client_credentials,
get_provider_for_connector,
save_tokens,
)
_ensure_connectors_registered()
if error:
_style = "font-family:system-ui;text-align:center;padding:60px"
return HTMLResponse(
content=(
f"<html><body style='{_style}'>"
f"<h2 style='color:#ef4444'>Authorization Failed</h2>"
f"<p>{error}</p>"
"<script>setTimeout(()=>window.close(),3000)</script>"
"</body></html>"
),
status_code=400,
)
if not code:
raise HTTPException(400, "Missing authorization code")
provider = get_provider_for_connector(connector_id)
if not provider:
raise HTTPException(400, f"No OAuth provider for '{connector_id}'")
creds = get_client_credentials(provider)
if not creds:
raise HTTPException(400, "No client credentials configured")
client_id, client_secret = creds
base_url = str(request.base_url).rstrip("/")
redirect_uri = f"{base_url}/v1/connectors/{connector_id}/oauth/callback"
try:
tokens = _exchange_token(
provider, code, client_id, client_secret, redirect_uri
)
except Exception as exc:
_style = "font-family:system-ui;text-align:center;padding:60px"
return HTMLResponse(
content=(
f"<html><body style='{_style}'>"
f"<h2 style='color:#ef4444'>Token Exchange Failed</h2>"
f"<p>{exc}</p>"
"</body></html>"
),
status_code=500,
)
payload = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_type": tokens.get("token_type", "Bearer"),
"expires_in": tokens.get("expires_in", 3600),
"client_id": client_id,
"client_secret": client_secret,
}
for filename in provider.credential_files:
save_tokens(str(_CONNECTORS_DIR / filename), payload)
# Clear cached instance so it picks up new credentials
_instances.pop(connector_id, None)
_style = "font-family:system-ui;text-align:center;padding:60px"
return HTMLResponse(
content=(
f"<html><body style='{_style}'>"
"<h2 style='color:#22c55e'>Connected!</h2>"
"<p>You can close this tab and return to OpenJarvis.</p>"
"<script>setTimeout(()=>window.close(),2000)</script>"
"</body></html>"
)
)
# Track background sync state per connector
_sync_threads: Dict[str, Any] = {}
_sync_state: Dict[str, Dict[str, Any]] = {} # {connector_id: {state, error}}
+124
View File
@@ -0,0 +1,124 @@
"""FastAPI routes for the morning digest."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from openjarvis.agents.digest_store import DigestStore
from openjarvis.cli.digest_cmd import (
_cancel_scheduler_tasks,
_create_scheduler_task,
_save_digest_schedule,
)
from openjarvis.core.config import load_config
class ScheduleUpdate(BaseModel):
"""Request body for updating the digest schedule."""
enabled: bool
cron: Optional[str] = None
def create_digest_router(*, db_path: str = "") -> APIRouter:
"""Create a digest API router with the given store path."""
router = APIRouter(prefix="/api/digest", tags=["digest"])
store = DigestStore(db_path=db_path) if db_path else DigestStore()
@router.get("")
async def get_digest():
"""Return the latest digest artifact."""
artifact = store.get_today()
if artifact is None:
raise HTTPException(status_code=404, detail="No digest for today")
return {
"text": artifact.text,
"sections": artifact.sections,
"sources_used": artifact.sources_used,
"generated_at": artifact.generated_at.isoformat(),
"model_used": artifact.model_used,
"voice_used": artifact.voice_used,
"audio_available": (
artifact.audio_path.exists() if artifact.audio_path.name else False
),
}
@router.get("/audio")
async def get_digest_audio():
"""Stream the digest audio file."""
artifact = store.get_today()
if artifact is None:
raise HTTPException(status_code=404, detail="No digest for today")
if not artifact.audio_path.exists():
raise HTTPException(status_code=404, detail="Audio not available")
return FileResponse(
str(artifact.audio_path),
media_type="audio/mpeg",
filename="digest.mp3",
)
@router.post("/generate")
async def generate_digest():
"""Force re-generation of the digest."""
try:
from openjarvis.sdk import Jarvis
with Jarvis() as j:
result = j.ask("Generate my morning digest", agent="morning_digest")
return {"status": "ok", "text": result}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/history")
async def get_digest_history():
"""Return past digests."""
history = store.history(limit=10)
return [
{
"text": a.text[:200],
"generated_at": a.generated_at.isoformat(),
"model_used": a.model_used,
"voice_used": a.voice_used,
}
for a in history
]
@router.get("/schedule")
async def get_schedule():
"""Return the current digest schedule configuration."""
cfg = load_config()
return {
"enabled": cfg.digest.enabled,
"cron": cfg.digest.schedule,
}
@router.post("/schedule")
async def update_schedule(body: ScheduleUpdate):
"""Update the digest schedule configuration."""
cfg = load_config()
cron = body.cron if body.cron is not None else cfg.digest.schedule
try:
_save_digest_schedule(enabled=body.enabled, cron=cron)
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to save config: {exc}",
)
# Sync with the TaskScheduler
if body.enabled:
_create_scheduler_task(cron)
else:
_cancel_scheduler_tasks()
return {
"enabled": body.enabled,
"cron": cron,
}
return router
+5
View File
@@ -41,10 +41,15 @@ class UsageInfo(BaseModel):
total_tokens: int = 0
class AudioMeta(BaseModel):
url: str
class ChoiceMessage(BaseModel):
role: str = "assistant"
content: Optional[str] = ""
tool_calls: Optional[List[Dict[str, Any]]] = None
audio: Optional[AudioMeta] = None
class Choice(BaseModel):
+16 -1
View File
@@ -263,11 +263,26 @@ def _handle_agent(
total_tokens=result.metadata.get("total_tokens", 0),
)
# Include audio metadata if the agent produced audio (e.g. morning digest)
audio_meta = None
audio_path = result.metadata.get("audio_path", "")
if audio_path:
from pathlib import Path
from openjarvis.server.models import AudioMeta
if Path(audio_path).exists():
audio_meta = AudioMeta(url="/api/digest/audio")
return ChatCompletionResponse(
model=model,
choices=[
Choice(
message=ChoiceMessage(role="assistant", content=result.content),
message=ChoiceMessage(
role="assistant",
content=result.content,
audio=audio_meta,
),
finish_reason="stop",
)
],
+1 -4
View File
@@ -171,10 +171,7 @@ async def ingest_files(
allowed = ", ".join(sorted(_ALLOWED_EXTENSIONS))
raise HTTPException(
status_code=400,
detail=(
f"Unsupported file type: {ext}. "
f"Allowed: {allowed}"
),
detail=(f"Unsupported file type: {ext}. Allowed: {allowed}"),
)
data = await upload.read()
+30 -31
View File
@@ -129,7 +129,9 @@ def create_webhook_router(
# Use bridge or app.state.channel_bridge
active_bridge = bridge or getattr(
request.app.state, "channel_bridge", None,
request.app.state,
"channel_bridge",
None,
)
def _handle_twilio() -> None:
@@ -140,7 +142,9 @@ def create_webhook_router(
# Get creds from app state bindings
mgr = getattr(
request.app.state, "agent_manager", None,
request.app.state,
"agent_manager",
None,
)
twilio_client = None
twilio_from = ""
@@ -153,20 +157,19 @@ def create_webhook_router(
sid = cfg.get("account_sid", "")
tok = cfg.get("auth_token", "")
twilio_from = cfg.get(
"phone_number", "",
"phone_number",
"",
)
if sid and tok:
twilio_client = Client(
sid, tok,
sid,
tok,
)
break
if twilio_client and twilio_from:
twilio_client.messages.create(
body=(
"Message received! "
"Working on it now..."
),
body=("Message received! Working on it now..."),
from_=twilio_from,
to=from_number,
)
@@ -177,7 +180,9 @@ def create_webhook_router(
response = ""
if active_bridge:
response = active_bridge.handle_incoming(
from_number, body, "twilio",
from_number,
body,
"twilio",
max_length=1600,
)
else:
@@ -191,16 +196,21 @@ def create_webhook_router(
)
engine = getattr(
request.app.state, "engine", None,
request.app.state,
"engine",
None,
)
if engine:
tools = _build_deep_research_tools(
engine=engine, model="",
engine=engine,
model="",
)
agent = DeepResearchAgent(
engine=engine,
model=getattr(
engine, "_model", "",
engine,
"_model",
"",
),
tools=tools,
max_turns=5,
@@ -224,7 +234,8 @@ def create_webhook_router(
)
except Exception as _e:
logger.warning(
"Twilio reply failed: %s", _e,
"Twilio reply failed: %s",
_e,
)
task = asyncio.create_task(
@@ -336,9 +347,7 @@ def create_webhook_router(
payload = await request.json()
# Get the SendBlue channel — may be passed at init or set later
sb = sendblue_channel or getattr(
request.app.state, "sendblue_channel", None
)
sb = sendblue_channel or getattr(request.app.state, "sendblue_channel", None)
# Verify webhook secret if configured
if sb and sb.webhook_secret:
@@ -364,18 +373,14 @@ def create_webhook_router(
# Capture sb for the closure
reply_channel = sb
# Also check for a dynamically-created bridge on app.state
active_bridge = bridge or getattr(
request.app.state, "channel_bridge", None
)
active_bridge = bridge or getattr(request.app.state, "channel_bridge", None)
if not active_bridge:
logger.warning("No channel bridge — cannot process SendBlue msg")
return Response("OK", status_code=200)
# Message queue tracking (per-sender)
_sendblue_queues = getattr(
request.app.state, "_sendblue_queues", None
)
_sendblue_queues = getattr(request.app.state, "_sendblue_queues", None)
if _sendblue_queues is None:
import threading as _th
@@ -390,9 +395,7 @@ def create_webhook_router(
# Track queue depth for this sender
with lock:
q = _sendblue_queues.setdefault(
from_number, {"pending": 0}
)
q = _sendblue_queues.setdefault(from_number, {"pending": 0})
q["pending"] += 1
position = q["pending"]
@@ -421,9 +424,7 @@ def create_webhook_router(
"Still working! Will reply ASAP",
)
reminder = threading.Thread(
target=_send_reminders, daemon=True
)
reminder = threading.Thread(target=_send_reminders, daemon=True)
reminder.start()
try:
@@ -437,9 +438,7 @@ def create_webhook_router(
# Format response for clean text message display
if response and reply_channel:
reply_channel.send(
from_number, _format_for_sms(response)
)
reply_channel.send(from_number, _format_for_sms(response))
task = asyncio.create_task(asyncio.to_thread(_handle_and_reply))
task.add_done_callback(_log_task_exception)
+9 -2
View File
@@ -1,10 +1,17 @@
"""Speech subsystem — speech-to-text backends."""
"""Speech subsystem — speech-to-text and text-to-speech backends."""
import importlib
# Optional backends — each registers itself via @SpeechRegistry.register()
# Optional STT backends — each registers itself via @SpeechRegistry.register()
for _mod in ("faster_whisper", "openai_whisper", "deepgram"):
try:
importlib.import_module(f".{_mod}", __name__)
except ImportError:
pass
# Optional TTS backends — each registers itself via @TTSRegistry.register()
for _mod in ("cartesia_tts", "kokoro_tts", "openai_tts"):
try:
importlib.import_module(f".{_mod}", __name__)
except ImportError:
pass
+109
View File
@@ -0,0 +1,109 @@
"""Cartesia text-to-speech backend.
Uses the Cartesia REST API for high-quality, low-latency voice synthesis.
Requires CARTESIA_API_KEY environment variable or config.
"""
from __future__ import annotations
import os
from typing import List
import httpx
from openjarvis.core.registry import TTSRegistry
from openjarvis.speech.tts import TTSBackend, TTSResult
_CARTESIA_API_BASE = "https://api.cartesia.ai"
def _cartesia_synthesize(
api_key: str,
text: str,
voice_id: str,
model: str = "sonic",
output_format: str = "mp3",
speed: float = 1.0,
) -> bytes:
"""Call the Cartesia TTS API and return raw audio bytes."""
resp = httpx.post(
f"{_CARTESIA_API_BASE}/tts/bytes",
headers={
"X-API-Key": api_key,
"Cartesia-Version": "2024-06-10",
},
json={
"model_id": model,
"transcript": text,
"voice": {"mode": "id", "id": voice_id},
"output_format": {
"container": output_format,
"sample_rate": 24000,
"encoding": "mp3" if output_format == "mp3" else "pcm_f32le",
},
"language": "en",
**({"speed": speed} if speed != 1.0 else {}),
},
timeout=120.0,
)
resp.raise_for_status()
return resp.content
@TTSRegistry.register("cartesia")
class CartesiaTTSBackend(TTSBackend):
"""Cartesia TTS backend — fast, high-quality synthesis."""
backend_id = "cartesia"
def __init__(self, *, api_key: str = "", model: str = "sonic") -> None:
self._api_key = api_key or os.environ.get("CARTESIA_API_KEY", "")
self._model = model
def synthesize(
self,
text: str,
*,
voice_id: str = "",
speed: float = 1.0,
output_format: str = "mp3",
) -> TTSResult:
if not self._api_key:
raise RuntimeError("CARTESIA_API_KEY not set")
# Default to "British Butler" voice — warm, authoritative, Jarvis-like
if not voice_id:
voice_id = "a0e99841-438c-4a64-b679-ae501e7d6091"
audio = _cartesia_synthesize(
self._api_key,
text,
voice_id=voice_id,
model=self._model,
output_format=output_format,
speed=speed,
)
return TTSResult(
audio=audio,
format=output_format,
voice_id=voice_id,
metadata={"backend": "cartesia", "model": self._model},
)
def available_voices(self) -> List[str]:
if not self._api_key:
return []
resp = httpx.get(
f"{_CARTESIA_API_BASE}/voices",
headers={
"X-API-Key": self._api_key,
"Cartesia-Version": "2024-06-10",
},
timeout=30.0,
)
resp.raise_for_status()
return [v["id"] for v in resp.json()]
def health(self) -> bool:
return bool(self._api_key)
+80
View File
@@ -0,0 +1,80 @@
"""Kokoro TTS backend — fully open-source, runs locally.
Requires the kokoro package: pip install kokoro
Falls back gracefully if not installed.
"""
from __future__ import annotations
import io
from typing import List
from openjarvis.core.registry import TTSRegistry
from openjarvis.speech.tts import TTSBackend, TTSResult
@TTSRegistry.register("kokoro")
class KokoroTTSBackend(TTSBackend):
"""Kokoro TTS — local open-source voice synthesis."""
backend_id = "kokoro"
def __init__(self, *, model_path: str = "", device: str = "auto") -> None:
self._model_path = model_path
self._device = device
self._pipeline = None
def _ensure_pipeline(self) -> None:
if self._pipeline is not None:
return
try:
from kokoro import KPipeline
self._pipeline = KPipeline(lang_code="a")
except ImportError:
raise RuntimeError(
"kokoro package not installed. Install with: pip install kokoro"
)
def synthesize(
self,
text: str,
*,
voice_id: str = "af_heart",
speed: float = 1.0,
output_format: str = "wav",
) -> TTSResult:
self._ensure_pipeline()
import numpy as np
import soundfile as sf
samples = []
for _, _, audio in self._pipeline(text, voice=voice_id, speed=speed):
samples.append(audio)
if not samples:
return TTSResult(audio=b"", format=output_format, voice_id=voice_id)
combined = np.concatenate(samples)
buf = io.BytesIO()
sf.write(buf, combined, 24000, format=output_format.upper())
buf.seek(0)
return TTSResult(
audio=buf.read(),
format=output_format,
voice_id=voice_id,
sample_rate=24000,
duration_seconds=len(combined) / 24000,
metadata={"backend": "kokoro"},
)
def available_voices(self) -> List[str]:
return ["af_heart", "af_bella", "am_adam", "am_michael"]
def health(self) -> bool:
try:
self._ensure_pipeline()
return True
except RuntimeError:
return False
+82
View File
@@ -0,0 +1,82 @@
"""OpenAI TTS backend — cloud-based voice synthesis via OpenAI API."""
from __future__ import annotations
import os
from typing import List
import httpx
from openjarvis.core.registry import TTSRegistry
from openjarvis.speech.tts import TTSBackend, TTSResult
_OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech"
def _openai_tts_request(
api_key: str,
text: str,
voice: str,
model: str = "tts-1",
speed: float = 1.0,
response_format: str = "mp3",
) -> bytes:
"""Call the OpenAI TTS API and return raw audio bytes."""
resp = httpx.post(
_OPENAI_TTS_URL,
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": model,
"input": text,
"voice": voice,
"speed": speed,
"response_format": response_format,
},
timeout=120.0,
)
resp.raise_for_status()
return resp.content
@TTSRegistry.register("openai_tts")
class OpenAITTSBackend(TTSBackend):
"""OpenAI TTS backend — cloud synthesis."""
backend_id = "openai_tts"
def __init__(self, *, api_key: str = "", model: str = "tts-1") -> None:
self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
self._model = model
def synthesize(
self,
text: str,
*,
voice_id: str = "nova",
speed: float = 1.0,
output_format: str = "mp3",
) -> TTSResult:
if not self._api_key:
raise RuntimeError("OPENAI_API_KEY not set")
audio = _openai_tts_request(
self._api_key,
text,
voice=voice_id,
model=self._model,
speed=speed,
response_format=output_format,
)
return TTSResult(
audio=audio,
format=output_format,
voice_id=voice_id,
metadata={"backend": "openai_tts", "model": self._model},
)
def available_voices(self) -> List[str]:
return ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
def health(self) -> bool:
return bool(self._api_key)
+53
View File
@@ -0,0 +1,53 @@
"""Abstract base classes and data types for text-to-speech backends."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List
@dataclass
class TTSResult:
"""Result of a text-to-speech synthesis."""
audio: bytes
format: str = "mp3"
duration_seconds: float = 0.0
voice_id: str = ""
sample_rate: int = 24000
metadata: Dict[str, Any] = field(default_factory=dict)
def save(self, path: Path) -> Path:
"""Write audio bytes to a file and return the path."""
path.write_bytes(self.audio)
return path
class TTSBackend(ABC):
"""Abstract base class for text-to-speech backends."""
backend_id: str = ""
@abstractmethod
def synthesize(
self,
text: str,
*,
voice_id: str = "",
speed: float = 1.0,
output_format: str = "mp3",
) -> TTSResult:
"""Synthesize text to audio."""
@abstractmethod
def available_voices(self) -> List[str]:
"""Return list of available voice IDs."""
@abstractmethod
def health(self) -> bool:
"""Check if the backend is ready."""
__all__ = ["TTSBackend", "TTSResult"]
+58 -1
View File
@@ -97,6 +97,11 @@ class JarvisSystem:
# Agent mode
use_agent = agent or self.agent_name
# Auto-detect agent intent if no explicit agent was requested
if not agent and use_agent != "none":
detected = self._detect_agent_intent(query)
if detected:
use_agent = detected
if use_agent and use_agent != "none":
return self._run_agent(
query,
@@ -124,6 +129,27 @@ class JarvisSystem:
"engine": self.engine_key,
}
def _detect_agent_intent(self, query: str) -> Optional[str]:
"""Detect if a query should be routed to a specific agent.
Uses lightweight pattern matching for known intent triggers.
Returns the agent name or None to use the default.
"""
import re
from openjarvis.core.registry import AgentRegistry
# Morning digest triggers
if re.search(
r"\b(good\s+morning|morning\s+digest|daily\s+briefing|morning\s+briefing)\b",
query,
re.IGNORECASE,
):
if AgentRegistry.contains("morning_digest"):
return "morning_digest"
return None
def _run_agent(
self,
query,
@@ -185,6 +211,34 @@ class JarvisSystem:
agent_kwargs["session_store"] = self.session_store
agent_kwargs["memory_backend"] = self.memory_backend
# Inject DigestConfig when instantiating the morning_digest agent
if agent_name == "morning_digest" and hasattr(self.config, "digest"):
dc = self.config.digest
section_sources = {}
for s in dc.sections:
sc = getattr(dc, s, None)
if sc and hasattr(sc, "sources"):
section_sources[s] = sc.sources
agent_kwargs.update(
{
"persona": dc.persona,
"sections": dc.sections,
"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
from openjarvis.tools.text_to_speech import TextToSpeechTool
digest_tools = [DigestCollectTool(), TextToSpeechTool()]
existing = agent_kwargs.get("tools", [])
agent_kwargs["tools"] = digest_tools + list(existing)
try:
ag = agent_cls(self.engine, self.model, **agent_kwargs)
except TypeError:
@@ -210,7 +264,9 @@ class JarvisSystem:
from openjarvis.traces.collector import TraceCollector
collector = TraceCollector(
ag, store=self.trace_store, bus=self.bus,
ag,
store=self.trace_store,
bus=self.bus,
)
result = collector.run(query, context=ctx)
self.trace_collector = collector
@@ -278,6 +334,7 @@ class JarvisSystem:
for tr in getattr(result, "tool_results", [])
],
"turns": getattr(result, "turns", 1),
"metadata": getattr(result, "metadata", {}),
"model": self.model,
"engine": self.engine_key,
"_telemetry": _telemetry,
+10
View File
@@ -131,4 +131,14 @@ try:
except ImportError:
pass
try:
import openjarvis.tools.text_to_speech # noqa: F401
except ImportError:
pass
try:
import openjarvis.tools.digest_collect # noqa: F401
except ImportError:
pass
__all__ = ["BaseTool", "ToolExecutor", "ToolSpec"]
+1 -4
View File
@@ -133,10 +133,7 @@ class ToolExecutor:
)
# Boundary guard: scan external tool arguments
if (
self._boundary_guard is not None
and not getattr(tool, "is_local", True)
):
if self._boundary_guard is not None and not getattr(tool, "is_local", True):
try:
tool_call = self._boundary_guard.check_outbound(tool_call)
# Re-parse arguments after potential redaction
+519
View File
@@ -0,0 +1,519 @@
"""Digest collection tool — fetches recent data from configured connectors."""
from __future__ import annotations
import json
import re
from datetime import datetime, timedelta
from typing import Any, Dict, List
from openjarvis.connectors._stubs import Document
from openjarvis.core.registry import ConnectorRegistry, ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
# ---------------------------------------------------------------------------
# Section definitions: ordered list of (section_name, connector_ids)
# ---------------------------------------------------------------------------
_SECTION_ORDER: List[tuple] = [
("HEALTH", {"oura", "apple_health", "strava"}),
(
"MESSAGES",
{
"gmail",
"gmail_imap",
"google_tasks",
"slack",
"imessage",
"whatsapp",
"outlook",
"notion",
"github_notifications",
},
),
("CALENDAR", {"gcalendar"}),
("WORLD", {"weather", "hackernews", "news_rss"}),
("MUSIC", {"spotify", "apple_music"}),
]
_CONNECTOR_TO_SECTION: Dict[str, str] = {}
for _section_name, _ids in _SECTION_ORDER:
for _cid in _ids:
_CONNECTOR_TO_SECTION[_cid] = _section_name
# ---------------------------------------------------------------------------
# Per-connector human-readable formatters
# ---------------------------------------------------------------------------
def _format_duration(seconds: float) -> str:
"""Format seconds as 'Xh Ym'."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
parts = []
if hours:
parts.append(f"{hours}h")
if minutes or not parts:
parts.append(f"{minutes}m")
return " ".join(parts)
def _time_ago(ts: datetime) -> str:
"""Return a human-readable relative time like '2h ago' or '15m ago'."""
now = datetime.now()
ts_naive = ts.replace(tzinfo=None) if ts.tzinfo else ts
delta = now - ts_naive
total_seconds = max(0, int(delta.total_seconds()))
if total_seconds < 60:
return "just now"
if total_seconds < 3600:
return f"{total_seconds // 60}m ago"
if total_seconds < 86400:
return f"{total_seconds // 3600}h ago"
days = total_seconds // 86400
return f"{days}d ago"
def _format_date(ts: datetime) -> str:
"""Format a datetime as 'April 1' style."""
return ts.strftime("%B %-d") if hasattr(ts, "strftime") else str(ts)
def _format_time(ts: datetime) -> str:
"""Format a datetime as '10:30 AM' style."""
return ts.strftime("%-I:%M %p") if hasattr(ts, "strftime") else str(ts)
def _parse_content_json(doc: Document) -> Dict[str, Any]:
"""Try to parse the document content as JSON; return {} on failure."""
try:
return json.loads(doc.content)
except (json.JSONDecodeError, TypeError):
return {}
def _format_oura(doc: Document) -> str:
"""Format an Oura Ring document into a readable line."""
data = _parse_content_json(doc)
data_type = doc.metadata.get("data_type", "")
day = doc.metadata.get("day", "")
day_str = day or _format_date(doc.timestamp)
if data_type == "sleep":
hr = data.get("average_heart_rate", "?")
hrv = data.get("average_hrv", data.get("average_heart_rate_variability", "?"))
total = data.get("total_sleep_duration")
awake = data.get("awake_time")
score = data.get("score")
parts = []
if score is not None:
parts.append(f"score {score}")
parts.append(f"avg HR {hr} bpm")
if hrv != "?":
parts.append(f"HRV {hrv}")
if total is not None:
parts.append(f"{_format_duration(total)} total sleep")
if awake is not None:
parts.append(f"awake {_format_duration(awake)}")
return f"[oura] Sleep — {day_str}: {', '.join(parts)}"
if data_type == "daily_readiness":
score = data.get("score", "?")
temp = data.get(
"temperature_deviation",
data.get("temperature_trend_deviation"),
)
line = f"[oura] Readiness — {day_str}: score {score}"
if temp is not None:
sign = "+" if temp >= 0 else ""
line += f", temperature deviation {sign}{temp}"
return line
if data_type == "daily_activity":
steps = data.get("steps", "?")
cal = data.get("total_calories", data.get("active_calories", "?"))
score = data.get("score")
parts = []
if score is not None:
parts.append(f"score {score}")
parts.append(f"steps {steps}")
parts.append(f"calories {cal}")
return f"[oura] Activity — {day_str}: {', '.join(parts)}"
# Fallback for unknown Oura doc types
return f"[oura] {doc.title}"
def _format_apple_health(doc: Document) -> str:
"""Format an Apple Health document."""
return f"[apple_health] {doc.title}"
def _format_strava(doc: Document) -> str:
"""Format a Strava activity document."""
return f"[strava] {doc.title}"
def _format_gmail(doc: Document) -> str:
"""Format a Gmail email document."""
sender = doc.author or "Unknown"
subject = doc.title or "(no subject)"
ago = _time_ago(doc.timestamp)
# 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:
"""Format a Gmail IMAP email document."""
sender = doc.author or "Unknown"
subject = doc.title or "(no subject)"
ago = _time_ago(doc.timestamp)
return f'[gmail] From: {sender}"{subject}" ({ago})'
def _format_google_tasks(doc: Document) -> str:
"""Format a Google Tasks document."""
title = doc.title or "Untitled Task"
status = doc.metadata.get("status", "")
due = doc.metadata.get("due", "")
parts = [f"[google_tasks] {title}"]
extras = []
if due:
extras.append(f"due {due}")
if status == "completed":
extras.append("completed")
if extras:
parts.append(f"({', '.join(extras)})")
return " ".join(parts)
def _format_slack(doc: Document) -> str:
"""Format a Slack message document."""
author = doc.author or "Unknown"
channel = doc.metadata.get("channel", "")
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]"
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"
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:
"""Format a WhatsApp message document."""
sender = doc.author or "Unknown"
content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else ""
return f"[whatsapp] {sender}: {content_preview}"
def _format_outlook(doc: Document) -> str:
"""Format an Outlook email document."""
sender = doc.author or "Unknown"
subject = doc.title or "(no subject)"
ago = _time_ago(doc.timestamp)
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)"
time_str = _format_time(doc.timestamp)
# Try to extract duration from content
duration_match = (
re.search(r"When:\s*(.+?)$", doc.content, re.MULTILINE) if doc.content else None
)
time_range = ""
if duration_match:
when = duration_match.group(1).strip()
# Extract just the times from the ISO strings
parts = when.split(" ")
if len(parts) == 2:
try:
start_dt = datetime.fromisoformat(parts[0].replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(parts[1].replace("Z", "+00:00"))
diff = end_dt - start_dt
mins = int(diff.total_seconds() / 60)
if mins >= 60:
hrs = mins // 60
remaining = mins % 60
duration = f"{hrs} hour" + ("s" if hrs > 1 else "")
if remaining:
duration += f" {remaining} min"
else:
duration = f"{mins} min"
time_range = f" ({duration})"
except (ValueError, TypeError):
pass
return f"[gcalendar] {time_str}{title}{time_range}"
def _format_spotify(doc: Document) -> str:
"""Format a Spotify recently-played track — returns 'Track — Artist'."""
# doc.title is already "Track — Artist" from the connector
return doc.title
def _format_apple_music(doc: Document) -> str:
"""Format an Apple Music track — returns 'Track — Artist'."""
# doc.title is already "Track — Artist" from the connector
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,
"apple_health": _format_apple_health,
"strava": _format_strava,
"gmail": _format_gmail,
"gmail_imap": _format_gmail_imap,
"google_tasks": _format_google_tasks,
"slack": _format_slack,
"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,
}
def _format_doc(source: str, doc: Document) -> str:
"""Format a document using the source-specific formatter, with fallback."""
formatter = _FORMATTERS.get(source)
if formatter:
try:
return formatter(doc)
except Exception: # noqa: BLE001
pass
# Fallback: connector name + title
return f"[{source}] {doc.title}"
def _format_music_section(
collected_docs: Dict[str, List[Document]],
music_connectors: set,
) -> List[str]:
"""Format music connectors as grouped lists instead of per-track lines."""
lines: List[str] = []
for source in sorted(music_connectors):
docs = collected_docs.get(source, [])
if not docs:
continue
tracks = []
for doc in docs:
tracks.append(doc.title)
label = "Recently played" if source == "spotify" else "Library"
lines.append(f"[{source}] {label}: {', '.join(tracks)}")
return lines
@ToolRegistry.register("digest_collect")
class DigestCollectTool(BaseTool):
"""Collect recent data from multiple connectors for digest synthesis."""
tool_id = "digest_collect"
is_local = True
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="digest_collect",
description=(
"Fetch recent data from configured connectors (email, calendar, "
"health, tasks, etc.) and return a structured, human-readable "
"summary grouped by section (Health, Messages, Calendar, Music) "
"for digest synthesis."
),
parameters={
"type": "object",
"properties": {
"sources": {
"type": "array",
"items": {"type": "string"},
"description": (
"List of connector IDs to fetch from "
"(e.g., ['gmail', 'oura', 'gcalendar'])."
),
},
"hours_back": {
"type": "number",
"description": "How many hours back to look (default: 24).",
},
},
"required": ["sources"],
},
category="data",
timeout_seconds=60.0,
)
def execute(self, **params: Any) -> ToolResult:
# Ensure connectors are registered
import openjarvis.connectors # noqa: F401
sources: List[str] = params.get("sources", [])
hours_back: float = params.get("hours_back", 24)
since = datetime.now() - timedelta(hours=hours_back)
# Collect raw documents per source
collected_docs: Dict[str, List[Document]] = {}
errors: List[str] = []
for source in sources:
if not ConnectorRegistry.contains(source):
errors.append(f"Connector '{source}' not available")
continue
try:
connector_cls = ConnectorRegistry.get(source)
connector = connector_cls()
if not connector.is_connected():
errors.append(
f"Connector '{source}' not connected (no credentials)"
)
continue
# Cap per-source to avoid overwhelming the LLM context
max_per_source = 15
docs: List[Document] = []
for d in connector.sync(since=since):
docs.append(d)
if len(docs) >= max_per_source:
break
collected_docs[source] = docs
except Exception as exc:
errors.append(f"Error fetching from '{source}': {exc}")
# Group by section and build human-readable output
summary_parts: List[str] = []
for section_name, section_connectors in _SECTION_ORDER:
# Gather all sources that belong to this section and have data
section_sources = [
s for s in sources if s in section_connectors and s in collected_docs
]
if not section_sources:
continue
section_lines: List[str] = []
if section_name == "MUSIC":
# Music gets special grouped formatting
section_lines = _format_music_section(
collected_docs, section_connectors
)
else:
for source in section_sources:
for doc in collected_docs[source]:
section_lines.append(_format_doc(source, doc))
if section_lines:
summary_parts.append(f"=== {section_name} ===")
summary_parts.extend(section_lines)
summary_parts.append("") # blank line between sections
# Handle any connectors not in a known section (fallback)
known_connectors = set()
for _, cids in _SECTION_ORDER:
known_connectors |= cids
uncategorized_sources = [
s for s in sources if s not in known_connectors and s in collected_docs
]
if uncategorized_sources:
summary_parts.append("=== OTHER ===")
for source in uncategorized_sources:
for doc in collected_docs[source]:
summary_parts.append(_format_doc(source, doc))
summary_parts.append("")
# Errors at the end, not inline
if errors:
summary_parts.append("=== ERRORS ===")
summary_parts.extend(errors)
return ToolResult(
tool_name="digest_collect",
content="\n".join(summary_parts),
success=True,
metadata={
"sources_queried": sources,
"sources_ok": list(collected_docs.keys()),
"sources_failed": errors,
"total_items": sum(len(v) for v in collected_docs.values()),
},
)
+106
View File
@@ -0,0 +1,106 @@
"""Text-to-speech tool — synthesize text to audio via configurable TTS backend."""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
from openjarvis.core.registry import ToolRegistry, TTSRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@ToolRegistry.register("text_to_speech")
class TextToSpeechTool(BaseTool):
"""Synthesize text into spoken audio using a TTS backend."""
tool_id = "text_to_speech"
is_local = False
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="text_to_speech",
description=(
"Convert text to spoken audio. Returns the file path to the "
"generated audio file."
),
parameters={
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to synthesize into speech.",
},
"voice_id": {
"type": "string",
"description": "Voice identifier for the TTS backend.",
},
"backend": {
"type": "string",
"description": "TTS backend (cartesia, kokoro, openai_tts).",
},
"output_dir": {
"type": "string",
"description": "Directory to save the audio file.",
},
},
"required": ["text"],
},
category="audio",
timeout_seconds=120.0,
)
def execute(self, **params: Any) -> ToolResult:
# Ensure TTS backends are registered
import openjarvis.speech # noqa: F401
text = params.get("text", "")
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(
tool_name="text_to_speech",
content="No text provided.",
success=False,
)
if not TTSRegistry.contains(backend_key):
return ToolResult(
tool_name="text_to_speech",
content=f"TTS backend '{backend_key}' not available.",
success=False,
)
backend_cls = TTSRegistry.get(backend_key)
backend = backend_cls()
result = backend.synthesize(text, voice_id=voice_id, speed=speed)
# Save to file
if output_dir:
out_dir = Path(output_dir)
else:
out_dir = Path(tempfile.mkdtemp(prefix="jarvis-tts-"))
out_dir.mkdir(parents=True, exist_ok=True)
ext = result.format or "mp3"
audio_path = out_dir / f"digest.{ext}"
result.save(audio_path)
return ToolResult(
tool_name="text_to_speech",
content=str(audio_path),
success=True,
metadata={
"audio_path": str(audio_path),
"format": ext,
"duration_seconds": result.duration_seconds,
"voice_id": result.voice_id,
"backend": backend_key,
},
)
+77
View File
@@ -0,0 +1,77 @@
"""Tests for DigestStore and DigestArtifact."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from openjarvis.agents.digest_store import DigestArtifact, DigestStore
def test_store_and_retrieve(tmp_path):
store = DigestStore(db_path=str(tmp_path / "digest.db"))
artifact = DigestArtifact(
text="Good morning sir.",
audio_path=Path("/tmp/digest.mp3"),
sections={"messages": "You have 3 emails.", "calendar": "2 meetings today."},
sources_used=["gmail", "gcalendar"],
generated_at=datetime(2026, 4, 1, 6, 0, 0),
model_used="claude-sonnet-4-6",
voice_used="jarvis-v1",
)
store.save(artifact)
retrieved = store.get_latest()
assert retrieved is not None
assert retrieved.text == "Good morning sir."
assert retrieved.sections["messages"] == "You have 3 emails."
assert retrieved.sources_used == ["gmail", "gcalendar"]
assert retrieved.voice_used == "jarvis-v1"
store.close()
def test_get_today(tmp_path):
store = DigestStore(db_path=str(tmp_path / "digest.db"))
artifact = DigestArtifact(
text="Today's digest",
audio_path=Path("/tmp/today.mp3"),
sections={"messages": "Nothing urgent."},
sources_used=["gmail"],
generated_at=datetime.now(),
model_used="test-model",
voice_used="jarvis",
)
store.save(artifact)
today = store.get_today()
assert today is not None
assert today.text == "Today's digest"
store.close()
def test_get_today_returns_none_when_empty(tmp_path):
store = DigestStore(db_path=str(tmp_path / "digest.db"))
assert store.get_today() is None
store.close()
def test_history(tmp_path):
store = DigestStore(db_path=str(tmp_path / "digest.db"))
for i in range(3):
store.save(
DigestArtifact(
text=f"Digest {i}",
audio_path=Path(f"/tmp/d{i}.mp3"),
sections={},
sources_used=[],
generated_at=datetime(2026, 4, 1 + i, 6, 0, 0),
model_used="test",
voice_used="jarvis",
)
)
history = store.history(limit=2)
assert len(history) == 2
assert history[0].text == "Digest 2" # Most recent first
store.close()
+71
View File
@@ -0,0 +1,71 @@
"""Tests for MorningDigestAgent."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from openjarvis.agents._stubs import AgentResult
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import ToolResult
def test_morning_digest_registered():
from openjarvis.agents.morning_digest import MorningDigestAgent
AgentRegistry.register_value("morning_digest", MorningDigestAgent)
assert AgentRegistry.contains("morning_digest")
def test_morning_digest_run(tmp_path):
from openjarvis.agents.morning_digest import MorningDigestAgent
mock_engine = MagicMock()
mock_engine.generate.return_value = {
"content": "Good morning sir. You have 3 emails and 2 meetings today.",
"finish_reason": "stop",
"usage": {},
}
# Mock collect result
mock_collect_result = ToolResult(
tool_name="digest_collect",
content='=== MESSAGES ===\n[gmail] From: alice@co.com — "Budget" (1h ago)\n',
success=True,
metadata={"total_items": 2},
)
# Mock TTS result
mock_tts_result = ToolResult(
tool_name="text_to_speech",
content=str(tmp_path / "digest.mp3"),
success=True,
metadata={"audio_path": str(tmp_path / "digest.mp3")},
)
agent = MorningDigestAgent(
mock_engine,
"test-model",
tools=[],
persona="neutral",
digest_store_path=str(tmp_path / "digest.db"),
)
with patch.object(
agent._executor,
"execute",
side_effect=[mock_collect_result, mock_tts_result],
):
result = agent.run("Generate morning digest")
assert isinstance(result, AgentResult)
assert "Good morning" in result.content
assert result.turns == 1
assert len(result.tool_results) == 2
def test_load_persona():
from openjarvis.agents.morning_digest import _load_persona
# Nonexistent persona returns empty string
result = _load_persona("nonexistent_persona_xyz")
assert result == ""
+44 -34
View File
@@ -28,6 +28,7 @@ def _register_sendblue():
# Helpers
# ---------------------------------------------------------------------------
def _make_channel(**overrides):
from openjarvis.channels.sendblue import SendBlueChannel
@@ -167,15 +168,17 @@ class TestWebhookHandler:
received = []
ch.on_message(lambda msg: received.append(msg))
ch.handle_webhook({
"from_number": "+19127130720",
"to_number": "+15551234567",
"content": "Hello Jarvis",
"message_handle": "msg-001",
"is_outbound": False,
"status": "RECEIVED",
"service": "iMessage",
})
ch.handle_webhook(
{
"from_number": "+19127130720",
"to_number": "+15551234567",
"content": "Hello Jarvis",
"message_handle": "msg-001",
"is_outbound": False,
"status": "RECEIVED",
"service": "iMessage",
}
)
assert len(received) == 1
assert received[0].sender == "+19127130720"
@@ -187,11 +190,13 @@ class TestWebhookHandler:
received = []
ch.on_message(lambda msg: received.append(msg))
ch.handle_webhook({
"from_number": "+15551234567",
"content": "Outbound message",
"is_outbound": True,
})
ch.handle_webhook(
{
"from_number": "+15551234567",
"content": "Outbound message",
"is_outbound": True,
}
)
assert len(received) == 0
@@ -200,11 +205,13 @@ class TestWebhookHandler:
received = []
ch.on_message(lambda msg: received.append(msg))
ch.handle_webhook({
"from_number": "+19127130720",
"content": "",
"is_outbound": False,
})
ch.handle_webhook(
{
"from_number": "+19127130720",
"content": "",
"is_outbound": False,
}
)
assert len(received) == 0
@@ -212,19 +219,20 @@ class TestWebhookHandler:
bus = EventBus(record_history=True)
ch = _make_channel(bus=bus)
ch.handle_webhook({
"from_number": "+19127130720",
"content": "Test",
"message_handle": "msg-002",
"is_outbound": False,
"service": "iMessage",
})
ch.handle_webhook(
{
"from_number": "+19127130720",
"content": "Test",
"message_handle": "msg-002",
"is_outbound": False,
"service": "iMessage",
}
)
event_types = [e.event_type for e in bus.history]
assert EventType.CHANNEL_MESSAGE_RECEIVED in event_types
event = [
e for e in bus.history
if e.event_type == EventType.CHANNEL_MESSAGE_RECEIVED
e for e in bus.history if e.event_type == EventType.CHANNEL_MESSAGE_RECEIVED
][0]
assert event.data["sender"] == "+19127130720"
assert event.data["service"] == "iMessage"
@@ -234,12 +242,14 @@ class TestWebhookHandler:
ch.on_message(lambda msg: 1 / 0) # Will raise ZeroDivisionError
# Should not raise
ch.handle_webhook({
"from_number": "+19127130720",
"content": "Test",
"message_handle": "msg-003",
"is_outbound": False,
})
ch.handle_webhook(
{
"from_number": "+19127130720",
"content": "Test",
"message_handle": "msg-003",
"is_outbound": False,
}
)
# ---------------------------------------------------------------------------
+54
View File
@@ -0,0 +1,54 @@
"""Tests for `jarvis digest` CLI command."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from click.testing import CliRunner
from openjarvis.agents.digest_store import DigestArtifact, DigestStore
def test_digest_command_exists():
"""The digest command is registered on the CLI."""
from openjarvis.cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["digest", "--help"])
assert result.exit_code == 0
assert "digest" in result.output.lower()
def test_digest_displays_cached(tmp_path):
from openjarvis.cli import cli
db_path = str(tmp_path / "digest.db")
store = DigestStore(db_path=db_path)
store.save(
DigestArtifact(
text="# Messages\nYou have 3 emails.\n# Calendar\n2 meetings today.",
audio_path=Path("/nonexistent/audio.mp3"),
sections={},
sources_used=["gmail"],
generated_at=datetime.now(),
model_used="test",
voice_used="jarvis",
)
)
store.close()
runner = CliRunner()
result = runner.invoke(cli, ["digest", "--text-only", "--db-path", db_path])
assert result.exit_code == 0
assert "3 emails" in result.output
def test_digest_no_cache(tmp_path):
from openjarvis.cli import cli
db_path = str(tmp_path / "empty.db")
runner = CliRunner()
result = runner.invoke(cli, ["digest", "--db-path", db_path])
assert result.exit_code == 0
assert "No digest for today" in result.output
+2
View File
@@ -21,6 +21,7 @@ from openjarvis.core.registry import (
RouterPolicyRegistry,
SpeechRegistry,
ToolRegistry,
TTSRegistry,
)
@@ -38,6 +39,7 @@ def _clean_registries() -> None:
SpeechRegistry.clear()
CompressionRegistry.clear()
ConnectorRegistry.clear()
TTSRegistry.clear()
reset_event_bus()
+161
View File
@@ -0,0 +1,161 @@
"""Tests for AppleHealthConnector -- local HealthKit DB / export XML."""
from __future__ import annotations
import json
from datetime import datetime
from openjarvis.connectors._stubs import Document
from openjarvis.core.registry import ConnectorRegistry
_SAMPLE_EXPORT_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<HealthData>
<Record type="HKQuantityTypeIdentifierStepCount" \
startDate="2024-03-15 08:00:00 -0700" \
endDate="2024-03-15 08:30:00 -0700" \
value="1234" sourceName="iPhone" unit="count"/>
<Record type="HKQuantityTypeIdentifierStepCount" \
startDate="2024-03-15 12:00:00 -0700" \
endDate="2024-03-15 12:15:00 -0700" \
value="500" sourceName="iPhone" unit="count"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" \
startDate="2024-03-15 22:00:00 -0700" \
endDate="2024-03-16 06:00:00 -0700" \
value="HKCategoryValueSleepAnalysisAsleepCore" sourceName="iPhone"/>
<Record type="HKQuantityTypeIdentifierHeartRate" \
startDate="2024-03-15 09:00:00 -0700" \
value="65" unit="count/min" sourceName="iPhone"/>
<Record type="HKQuantityTypeIdentifierHeartRate" \
startDate="2024-03-15 15:00:00 -0700" \
value="75" unit="count/min" sourceName="iPhone"/>
<Record type="HKQuantityTypeIdentifierActiveEnergyBurned" \
startDate="2024-03-15 07:30:00 -0700" \
value="350" unit="kcal" sourceName="iPhone"/>
<Workout workoutActivityType="HKWorkoutActivityTypeRunning" \
duration="1800" \
startDate="2024-03-15 07:00:00 -0700" \
endDate="2024-03-15 07:30:00 -0700" \
totalDistance="5.2" totalEnergyBurned="300"/>
</HealthData>
"""
def test_apple_health_registered():
"""AppleHealthConnector is discoverable via ConnectorRegistry."""
from openjarvis.connectors.apple_health import AppleHealthConnector
ConnectorRegistry.register_value("apple_health", AppleHealthConnector)
assert ConnectorRegistry.contains("apple_health")
cls = ConnectorRegistry.get("apple_health")
assert cls.connector_id == "apple_health"
assert cls.display_name == "Apple Health"
assert cls.auth_type == "local"
def test_not_connected_no_files(tmp_path):
"""is_connected() returns False when neither data source exists."""
from openjarvis.connectors.apple_health import AppleHealthConnector
connector = AppleHealthConnector(
export_path=str(tmp_path / "nope.xml"),
healthkit_db_path=str(tmp_path / "nope.sqlite"),
)
assert connector.is_connected() is False
def test_connected_with_export(tmp_path):
"""is_connected() returns True when the export XML exists."""
from openjarvis.connectors.apple_health import AppleHealthConnector
export_file = tmp_path / "export.xml"
export_file.write_text(_SAMPLE_EXPORT_XML, encoding="utf-8")
connector = AppleHealthConnector(
export_path=str(export_file),
healthkit_db_path=str(tmp_path / "nope.sqlite"),
)
assert connector.is_connected() is True
def test_sync_export_xml(tmp_path):
"""Sync from export XML yields correct Documents for each data type."""
from openjarvis.connectors.apple_health import AppleHealthConnector
export_file = tmp_path / "export.xml"
export_file.write_text(_SAMPLE_EXPORT_XML, encoding="utf-8")
connector = AppleHealthConnector(
export_path=str(export_file),
healthkit_db_path=str(tmp_path / "nope.sqlite"),
)
docs = list(connector.sync())
assert all(isinstance(d, Document) for d in docs)
by_type = {}
for d in docs:
by_type.setdefault(d.doc_type, []).append(d)
# Steps: two records on 2024-03-15 should be summed (1234 + 500 = 1734)
assert "steps" in by_type
step_doc = by_type["steps"][0]
assert step_doc.source == "apple_health"
step_data = json.loads(step_doc.content)
assert step_data["steps"] == 1734
assert "1,734" in step_doc.title
# Sleep
assert "sleep" in by_type
sleep_doc = by_type["sleep"][0]
sleep_data = json.loads(sleep_doc.content)
assert sleep_data["total_seconds"] == 28800.0 # 8 hours
assert "8h" in sleep_doc.title
# Heart rate: avg of 65 and 75 = 70
assert "heart_rate" in by_type
hr_doc = by_type["heart_rate"][0]
hr_data = json.loads(hr_doc.content)
assert hr_data["avg_bpm"] == 70.0
assert hr_data["min_bpm"] == 65.0
assert hr_data["max_bpm"] == 75.0
# Active energy
assert "active_energy" in by_type
energy_doc = by_type["active_energy"][0]
energy_data = json.loads(energy_doc.content)
assert energy_data["kcal"] == 350.0
# Workout
assert "workout" in by_type
workout_doc = by_type["workout"][0]
assert "Running" in workout_doc.title
assert "5.2" in workout_doc.title
workout_data = json.loads(workout_doc.content)
assert workout_data["duration_seconds"] == 1800.0
def test_sync_with_since_filter(tmp_path):
"""Only records after the ``since`` date are included."""
from openjarvis.connectors.apple_health import AppleHealthConnector
export_file = tmp_path / "export.xml"
export_file.write_text(_SAMPLE_EXPORT_XML, encoding="utf-8")
connector = AppleHealthConnector(
export_path=str(export_file),
healthkit_db_path=str(tmp_path / "nope.sqlite"),
)
# All sample data is from 2024-03-15; filtering after that should yield nothing
docs = list(connector.sync(since=datetime(2025, 1, 1)))
assert len(docs) == 0
def test_disconnect_is_noop(tmp_path):
"""disconnect() does not raise for a local connector."""
from openjarvis.connectors.apple_health import AppleHealthConnector
connector = AppleHealthConnector(
export_path=str(tmp_path / "nope.xml"),
healthkit_db_path=str(tmp_path / "nope.sqlite"),
)
connector.disconnect() # should not raise
+177
View File
@@ -0,0 +1,177 @@
"""Tests for AppleMusicConnector -- local Music.app via AppleScript.
All tests mock ``subprocess.run`` so no actual Music.app interaction is needed.
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import List
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.connectors._stubs import Document
from openjarvis.core.registry import ConnectorRegistry
# ---------------------------------------------------------------------------
# Sample AppleScript output
# ---------------------------------------------------------------------------
_SAMPLE_OUTPUT = (
"Bohemian Rhapsody|||Queen|||A Night at the Opera|||354.0|||Rock|||42|||"
"Saturday, March 15, 2026 at 2:30:00 PM\n"
"Blinding Lights|||The Weeknd|||After Hours|||200.0|||Pop|||18|||never\n"
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def connector():
"""Return a fresh AppleMusicConnector."""
from openjarvis.connectors.apple_music import AppleMusicConnector
return AppleMusicConnector()
# ---------------------------------------------------------------------------
# Test 1 -- registry
# ---------------------------------------------------------------------------
def test_apple_music_registered():
"""AppleMusicConnector is discoverable via ConnectorRegistry."""
from openjarvis.connectors.apple_music import AppleMusicConnector
ConnectorRegistry.register_value("apple_music", AppleMusicConnector)
assert ConnectorRegistry.contains("apple_music")
cls = ConnectorRegistry.get("apple_music")
assert cls.connector_id == "apple_music"
assert cls.display_name == "Apple Music"
assert cls.auth_type == "local"
# ---------------------------------------------------------------------------
# Test 2 -- is_connected on macOS (happy path)
# ---------------------------------------------------------------------------
def test_is_connected_on_macos(connector):
"""is_connected() returns True on macOS when Music.app responds."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "Library"
with (
patch("openjarvis.connectors.apple_music.sys") as mock_sys,
patch("openjarvis.connectors.apple_music.subprocess") as mock_subprocess,
):
mock_sys.platform = "darwin"
mock_subprocess.run.return_value = mock_result
mock_subprocess.TimeoutExpired = TimeoutError
assert connector.is_connected() is True
# ---------------------------------------------------------------------------
# Test 3 -- is_connected on Linux
# ---------------------------------------------------------------------------
def test_is_connected_on_linux(connector):
"""is_connected() returns False on non-macOS platforms."""
with patch("openjarvis.connectors.apple_music.sys") as mock_sys:
mock_sys.platform = "linux"
assert connector.is_connected() is False
# ---------------------------------------------------------------------------
# Test 4 -- sync yields Documents
# ---------------------------------------------------------------------------
def test_sync_yields_tracks(connector):
"""sync() parses AppleScript output and yields correct Documents."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = _SAMPLE_OUTPUT
with patch(
"openjarvis.connectors.apple_music._run_osascript",
return_value=_SAMPLE_OUTPUT,
):
docs: List[Document] = list(connector.sync())
assert len(docs) == 2
assert all(isinstance(d, Document) for d in docs)
assert all(d.source == "apple_music" for d in docs)
assert all(d.doc_type == "track" for d in docs)
# First track
assert docs[0].author == "Queen"
assert "Bohemian Rhapsody" in docs[0].title
content0 = json.loads(docs[0].content)
assert content0["album"] == "A Night at the Opera"
assert content0["play_count"] == 42
assert docs[0].metadata["genre"] == "Rock"
assert docs[0].metadata["duration_s"] == 354.0
# Second track
assert docs[1].author == "The Weeknd"
assert "Blinding Lights" in docs[1].title
content1 = json.loads(docs[1].content)
assert content1["genre"] == "Pop"
assert docs[1].metadata["play_count"] == 18
# ---------------------------------------------------------------------------
# Test 5 -- sync with since filter
# ---------------------------------------------------------------------------
def test_sync_filters_by_since(connector):
"""sync(since=...) skips tracks with played_date before the threshold."""
with patch(
"openjarvis.connectors.apple_music._run_osascript",
return_value=_SAMPLE_OUTPUT,
):
# The first track was played 2026-03-15; filter after that
docs = list(connector.sync(since=datetime(2026, 3, 16)))
# Only tracks with played_date > since are included.
# "Blinding Lights" has played_date "never" so since filter is not applied
# (since filter only applies when played_date is not None).
# "Bohemian Rhapsody" was played 2026-03-15 which is <= 2026-03-16, so skipped.
assert len(docs) == 1
assert "Blinding Lights" in docs[0].title
# ---------------------------------------------------------------------------
# Test 6 -- sync handles Music.app failure gracefully
# ---------------------------------------------------------------------------
def test_sync_handles_failure(connector):
"""sync() returns empty when AppleScript fails."""
with patch(
"openjarvis.connectors.apple_music._run_osascript",
return_value=None,
):
docs = list(connector.sync())
assert len(docs) == 0
assert connector.sync_status().state == "error"
# ---------------------------------------------------------------------------
# Test 7 -- disconnect is a no-op
# ---------------------------------------------------------------------------
def test_disconnect_is_noop(connector):
"""disconnect() does nothing (local connector)."""
connector.disconnect() # should not raise
+12 -4
View File
@@ -35,7 +35,9 @@ _ALL_CONNECTORS = _LOCAL_CONNECTORS + _TOKEN_CONNECTORS
ids=[c[0] for c in _ALL_CONNECTORS],
)
def test_connector_instantiates(
connector_id: str, module_path: str, class_name: str,
connector_id: str,
module_path: str,
class_name: str,
) -> None:
"""Every connector can be instantiated without errors."""
import importlib
@@ -52,7 +54,9 @@ def test_connector_instantiates(
ids=[c[0] for c in _ALL_CONNECTORS],
)
def test_connector_has_required_methods(
connector_id: str, module_path: str, class_name: str,
connector_id: str,
module_path: str,
class_name: str,
) -> None:
"""Every connector implements the BaseConnector interface."""
import importlib
@@ -75,7 +79,9 @@ def test_connector_has_required_methods(
ids=[c[0] for c in _TOKEN_CONNECTORS],
)
def test_connector_not_connected_without_creds(
connector_id: str, module_path: str, class_name: str,
connector_id: str,
module_path: str,
class_name: str,
) -> None:
"""Token connectors report not connected without credentials."""
import importlib
@@ -96,7 +102,9 @@ def test_connector_not_connected_without_creds(
ids=[c[0] for c in _ALL_CONNECTORS],
)
def test_connector_has_metadata(
connector_id: str, module_path: str, class_name: str,
connector_id: str,
module_path: str,
class_name: str,
) -> None:
"""Every connector has connector_id, display_name, and auth_type."""
import importlib
+7 -1
View File
@@ -57,10 +57,16 @@ def _make_credentials(tmp_path: Path) -> Path:
@pytest.fixture()
def connector(tmp_path: Path):
"""GCalendarConnector pointing at a tmp credentials path (no file yet)."""
from unittest.mock import patch
from openjarvis.connectors.gcalendar import GCalendarConnector # noqa: PLC0415
creds_path = str(tmp_path / "gcalendar.json")
return GCalendarConnector(credentials_path=creds_path)
with patch(
"openjarvis.connectors.oauth._SHARED_GOOGLE_CREDENTIALS_PATH",
str(tmp_path / "google_shared.json"),
):
yield GCalendarConnector(credentials_path=creds_path)
# ---------------------------------------------------------------------------
+6 -1
View File
@@ -55,7 +55,12 @@ def connector(tmp_path: Path):
from openjarvis.connectors.gdrive import GDriveConnector # noqa: PLC0415
creds_path = str(tmp_path / "gdrive.json")
return GDriveConnector(credentials_path=creds_path)
# Prevent fallback to shared google.json on machines with real credentials
with patch(
"openjarvis.connectors.oauth._SHARED_GOOGLE_CREDENTIALS_PATH",
str(tmp_path / "google_shared.json"),
):
yield GDriveConnector(credentials_path=creds_path)
# ---------------------------------------------------------------------------
@@ -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

Some files were not shown because too many files have changed in this diff Show More