diff --git a/examples/granola/honcho_granola.py b/examples/granola/honcho_granola.py
index 4babb0cd..b3881eba 100644
--- a/examples/granola/honcho_granola.py
+++ b/examples/granola/honcho_granola.py
@@ -26,29 +26,34 @@ import sys
import threading
import traceback
import webbrowser
+from dataclasses import dataclass, field
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
-from typing import Any, TypedDict
+from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
-class Participant(TypedDict):
+@dataclass
+class Participant:
name: str
- email: str | None
- org: str | None
+ email: str | None = None
+ org: str | None = None
-class ParsedParticipants(TypedDict):
- note_creator: Participant | None
- others: list[Participant]
+@dataclass
+class ParsedParticipants:
+ note_creator: Participant | None = None
+ others: list[Participant] = field(default_factory=list)
-class TranscriptTurn(TypedDict):
+@dataclass
+class TranscriptTurn:
speaker: str
text: str
+
# Granola MCP + OAuth endpoints
GRANOLA_MCP_URL = "https://mcp.granola.ai/mcp"
AUTH_BASE = "https://mcp-auth.granola.ai"
@@ -58,32 +63,28 @@ OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_REDIRECT_PORT}/callback"
# Honcho message size limit (25000 max, leave headroom)
MAX_MESSAGE_LEN = 24000
-# Module-level state
-http_client = httpx.AsyncClient(timeout=60.0)
-access_token: str | None = None
-auth_result: dict[str, str | None] = {"code": None, "error": None}
-
# ---------------------------------------------------------------------------
# OAuth callback handler (must be a class for BaseHTTPRequestHandler)
# ---------------------------------------------------------------------------
class _OAuthCallback(BaseHTTPRequestHandler):
+ auth_result: dict[str, str | None] = {"code": None, "error": None}
+
def do_GET(self):
- global auth_result
params = parse_qs(urlparse(self.path).query)
if "code" in params:
- auth_result["code"] = params["code"][0]
+ _OAuthCallback.auth_result["code"] = params["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"
Authenticated! You can close this window.
")
elif "error" in params:
- auth_result["error"] = params.get("error_description", params["error"])[0]
+ _OAuthCallback.auth_result["error"] = params.get("error_description", params["error"])[0]
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
- self.wfile.write(f"Error: {auth_result['error']}
".encode())
+ self.wfile.write(f"Error: {_OAuthCallback.auth_result['error']}
".encode())
else:
self.send_response(404)
self.end_headers()
@@ -96,10 +97,9 @@ class _OAuthCallback(BaseHTTPRequestHandler):
# Granola OAuth + MCP
# ---------------------------------------------------------------------------
-async def authenticate() -> bool:
- """Perform OAuth (DCR + PKCE) with Granola. Sets module-level access_token."""
- global access_token, auth_result
- auth_result = {"code": None, "error": None}
+async def authenticate(http_client: httpx.AsyncClient) -> str:
+ """Perform OAuth (DCR + PKCE) with Granola. Returns access token."""
+ _OAuthCallback.auth_result = {"code": None, "error": None}
print("\nAuthenticating with Granola...")
@@ -115,8 +115,7 @@ async def authenticate() -> bool:
},
)
if resp.status_code not in (200, 201):
- print(f" Client registration failed: {resp.status_code}")
- return False
+ raise RuntimeError(f"Client registration failed: {resp.status_code}")
client_id = resp.json().get("client_id")
# PKCE
@@ -142,12 +141,11 @@ async def authenticate() -> bool:
thread.join(timeout=120)
server.server_close()
+ auth_result = _OAuthCallback.auth_result
if auth_result["error"]:
- print(f" Authentication failed: {auth_result['error']}")
- return False
+ raise RuntimeError(f"Authentication failed: {auth_result['error']}")
if not auth_result["code"]:
- print(" Authentication timed out")
- return False
+ raise RuntimeError("Authentication timed out")
# Exchange code for token
resp = await http_client.post(
@@ -162,15 +160,18 @@ async def authenticate() -> bool:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
- print(f" Token exchange failed: {resp.status_code}")
- return False
+ raise RuntimeError(f"Token exchange failed: {resp.status_code}")
- access_token = resp.json().get("access_token")
print(" Authenticated successfully!")
- return True
+ return resp.json()["access_token"]
-async def call_mcp_tool(tool_name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
+async def call_mcp_tool(
+ http_client: httpx.AsyncClient,
+ access_token: str,
+ tool_name: str,
+ arguments: dict[str, Any] | None = None,
+) -> dict[str, Any]:
"""Call a Granola MCP tool, handling both JSON and SSE responses."""
resp = await http_client.post(
GRANOLA_MCP_URL,
@@ -187,7 +188,7 @@ async def call_mcp_tool(tool_name: str, arguments: dict[str, Any] | None = None)
},
)
if resp.status_code != 200:
- raise Exception(f"MCP call failed: {resp.status_code} - {resp.text}")
+ raise RuntimeError(f"MCP call failed: {resp.status_code} - {resp.text}")
# SSE response
if "text/event-stream" in resp.headers.get("content-type", ""):
@@ -199,38 +200,44 @@ async def call_mcp_tool(tool_name: str, arguments: dict[str, Any] | None = None)
if "result" in parsed:
result = parsed
elif "error" in parsed:
- raise Exception(f"MCP error: {parsed['error']}")
+ raise RuntimeError(f"MCP error: {parsed['error']}")
except json.JSONDecodeError:
continue
if result:
final = result.get("result", {})
return final if isinstance(final, dict) else {"result": final}
- raise Exception("No result in SSE response")
+ raise RuntimeError("No result in SSE response")
# JSON response
result = resp.json()
if "error" in result:
- raise Exception(f"MCP error: {result['error']}")
+ raise RuntimeError(f"MCP error: {result['error']}")
return result.get("result", {})
def extract_mcp_text(result: dict[str, Any]) -> str:
- """Extract text from the first content block of an MCP result."""
+ """Extract text from the first content block of an MCP result.
+
+ Raises ValueError if the response structure is unexpected.
+ """
content = result.get("content", [])
- if isinstance(content, list) and content:
- first = content[0]
- if isinstance(first, dict) and "text" in first:
- return str(first["text"])
- return ""
+ if not isinstance(content, list) or not content:
+ raise ValueError(f"MCP response missing content array: {list(result.keys())}")
+ first = content[0]
+ if not isinstance(first, dict) or "text" not in first:
+ raise ValueError(f"MCP content block missing 'text' field: {first}")
+ return str(first["text"])
# ---------------------------------------------------------------------------
# Granola data fetching
# ---------------------------------------------------------------------------
-async def list_meetings(limit: int = 100) -> list[dict[str, Any]]:
+async def list_meetings(
+ http_client: httpx.AsyncClient, access_token: str, limit: int = 100,
+) -> list[dict[str, Any]]:
"""List meetings from Granola MCP. Parses Granola's XML-like response format."""
- result = await call_mcp_tool("list_meetings", {"limit": limit})
+ result = await call_mcp_tool(http_client, access_token, "list_meetings", {"limit": limit})
text = extract_mcp_text(result)
meetings: list[dict[str, Any]] = []
@@ -249,37 +256,91 @@ async def list_meetings(limit: int = 100) -> list[dict[str, Any]]:
return meetings
-async def get_meeting_details(meeting_id: str) -> dict[str, Any]:
- """Get full meeting details including notes. Returns raw XML-like content."""
- result = await call_mcp_tool("get_meetings", {"meeting_ids": [meeting_id]})
+async def get_meeting_details(
+ http_client: httpx.AsyncClient, access_token: str, meeting_id: str,
+) -> dict[str, Any]:
+ """Get full meeting details including notes."""
+ result = await call_mcp_tool(http_client, access_token, "get_meetings", {"meeting_ids": [meeting_id]})
text = extract_mcp_text(result)
return {"id": meeting_id, "raw_content": text}
-async def get_meeting_transcript(meeting_id: str) -> str | None:
- """Get transcript for a meeting (paid tiers only)."""
- try:
- result = await call_mcp_tool("get_meeting_transcript", {"meeting_id": meeting_id})
- except Exception as e:
- print(f" Transcript unavailable: {e}")
- return None
+async def get_meeting_transcript(
+ http_client: httpx.AsyncClient, access_token: str, meeting_id: str,
+ max_retries: int = 3,
+) -> str | None:
+ """Get transcript for a meeting (paid tiers only).
+
+ Retries on rate limit responses with exponential backoff.
+ """
+ for attempt in range(max_retries):
+ try:
+ result = await call_mcp_tool(http_client, access_token, "get_meeting_transcript", {"meeting_id": meeting_id})
+ text = extract_mcp_text(result)
+ except Exception as e:
+ print(f" Transcript unavailable: {e}")
+ return None
+
+ if not text or "no transcript" in text.lower():
+ return None
+
+ # Granola returns rate limit errors as content text, not HTTP errors
+ if "rate limit" in text.lower():
+ wait = 2 ** attempt * 3 # 3s, 6s, 12s
+ print(f" ⚠ Granola rate limit hit (attempt {attempt + 1}/{max_retries}), waiting {wait}s...")
+ await asyncio.sleep(wait)
+ continue
- text = extract_mcp_text(result)
- if text and "no transcript" not in text.lower():
return text
- if not text:
- t = result.get("transcript")
- return str(t) if t else None
+
+ print(f" ⚠ Transcript skipped after {max_retries} rate limit retries")
return None
+async def fetch_all_meetings(
+ http_client: httpx.AsyncClient, access_token: str,
+) -> list[dict[str, Any]]:
+ """Fetch meeting list and enrich each with transcript and details."""
+ print("\nFetching meetings from Granola...")
+ meetings = await list_meetings(http_client, access_token, limit=500)
+ if not meetings:
+ print("No meetings found.")
+ return []
+ print(f" Found {len(meetings)} meetings. Fetching content...\n")
+
+ for i, m in enumerate(meetings, 1):
+ mid = m.get("id")
+ if not mid:
+ continue
+
+ transcript = await get_meeting_transcript(http_client, access_token, mid)
+ if transcript:
+ m["transcript"] = transcript
+
+ try:
+ m.update(await get_meeting_details(http_client, access_token, mid))
+ except Exception as exc:
+ print(f" Failed to fetch details for {mid}: {exc}")
+
+ has_t = "transcript" in m
+ has_s = bool(extract_summary(m))
+ label = "transcript+summary" if has_t and has_s else "transcript only" if has_t else "summary only" if has_s else "basic only"
+ print(f" [{i}/{len(meetings)}] {label}: {m.get('title', 'Untitled')[:45]}")
+ await asyncio.sleep(1.5) # rate limit
+
+ return meetings
+
+
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def parse_participants(participants_str: str) -> ParsedParticipants:
- """Parse Granola's participant string into {note_creator, others}."""
- result: ParsedParticipants = {"note_creator": None, "others": []}
+ """Parse Granola's participant string into structured participants.
+
+ Warns on unparseable entries instead of silently dropping them.
+ """
+ result = ParsedParticipants()
if not participants_str:
return result
@@ -310,22 +371,26 @@ def parse_participants(participants_str: str) -> ParsedParticipants:
email = email_match.group(1) if email_match else None
name = re.sub(r"\s*<[^>]+>", "", clean).strip()
+ if not name:
+ print(f" Warning: could not parse participant entry: {entry!r}")
+ continue
+
org = None
org_match = re.match(r"(.+?)\s+from\s+(.+)", name)
if org_match:
name, org = org_match.group(1).strip(), org_match.group(2).strip()
- person: Participant = {"name": name, "email": email, "org": org}
+ person = Participant(name=name, email=email, org=org)
if is_creator:
- result["note_creator"] = person
+ result.note_creator = person
else:
- result["others"].append(person)
+ result.others.append(person)
return result
def parse_transcript_turns(raw: str) -> list[TranscriptTurn]:
- """Split a Granola transcript into speaker turns [{speaker, text}, ...]."""
+ """Split a Granola transcript into speaker turns."""
# Unwrap JSON wrapper if present
try:
parsed = json.loads(raw)
@@ -340,7 +405,7 @@ def parse_transcript_turns(raw: str) -> list[TranscriptTurn]:
while i < len(parts) - 1:
text = parts[i + 1].strip()
if text:
- turns.append({"speaker": parts[i], "text": text})
+ turns.append(TranscriptTurn(speaker=parts[i], text=text))
i += 2
return turns
@@ -379,13 +444,199 @@ def sanitize(text: str) -> str:
def parse_date(date_str: str) -> datetime:
- """Parse Granola's date format into a timezone-aware datetime."""
+ """Parse Granola's date format into a timezone-aware datetime.
+
+ Raises ValueError if the date string doesn't match any known format.
+ """
for fmt in ["%b %d, %Y %I:%M %p", "%b %d, %Y %I:%M:%S %p", "%B %d, %Y %I:%M %p"]:
try:
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
- return datetime.now(timezone.utc)
+ raise ValueError(f"Unrecognized date format: {date_str!r}")
+
+
+# ---------------------------------------------------------------------------
+# Honcho import helpers
+# ---------------------------------------------------------------------------
+
+def build_messages(
+ peer: Any,
+ content: str,
+ metadata: dict[str, object] | None,
+ created_at: datetime,
+) -> list[Any]:
+ """Build chunked messages for a single peer, attaching metadata to the first chunk."""
+ messages = []
+ content = sanitize(content)
+ for start in range(0, len(content), MAX_MESSAGE_LEN):
+ chunk = content[start:start + MAX_MESSAGE_LEN]
+ msg_meta = metadata if start == 0 else None
+ messages.append(peer.message(chunk, metadata=msg_meta, created_at=created_at))
+ return messages
+
+
+def send_messages(session: Any, messages: list[Any]) -> None:
+ """Send messages to a session in batches of 100."""
+ for batch_start in range(0, len(messages), 100):
+ session.add_messages(messages[batch_start:batch_start + 100])
+
+
+def import_two_person(
+ honcho: Any,
+ session: Any,
+ me_peer_id: str,
+ them_peer_id: str,
+ turns: list[TranscriptTurn],
+ metadata: dict[str, object],
+ created_at: datetime,
+) -> None:
+ """Import a two-person meeting with speaker attribution."""
+ me_peer = honcho.peer(me_peer_id)
+ them_peer = honcho.peer(them_peer_id)
+
+ # Merge consecutive same-speaker turns
+ merged: list[TranscriptTurn] = []
+ for t in turns:
+ if merged and merged[-1].speaker == t.speaker:
+ merged[-1].text += " " + t.text
+ else:
+ merged.append(TranscriptTurn(speaker=t.speaker, text=t.text))
+
+ messages: list[Any] = []
+ for i, t in enumerate(merged):
+ peer = me_peer if t.speaker == "Me" else them_peer
+ msg_meta = metadata if i == 0 else None
+ messages.extend(build_messages(peer, t.text, msg_meta, created_at))
+
+ send_messages(session, messages)
+ print(f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})")
+
+
+def import_summary(
+ honcho: Any,
+ session: Any,
+ me_peer_id: str,
+ meeting: dict[str, Any],
+ metadata: dict[str, object],
+ created_at: datetime,
+) -> None:
+ """Import a meeting as a summary message."""
+ me_peer = honcho.peer(me_peer_id)
+ summary = extract_summary(meeting)
+ if not summary:
+ raw_t = meeting.get("transcript", "")
+ try:
+ parsed = json.loads(raw_t)
+ summary = str(parsed.get("transcript", "")) if isinstance(parsed, dict) else raw_t
+ except (json.JSONDecodeError, TypeError):
+ summary = raw_t
+ summary = summary or "No content available"
+
+ title = meeting.get("title", "Untitled")
+ date = meeting.get("date", "")
+ header = f"Meeting: {title}\nDate: {date}\nParticipants: {meeting.get('participants', '')}\n\n"
+
+ messages = build_messages(me_peer, header + summary, metadata, created_at)
+ send_messages(session, messages)
+ print(" -> Imported as summary")
+
+
+def resolve_them_participant(others: list[Participant]) -> Participant | None:
+ """Ask user to pick which participant is 'Them' from a multi-person meeting."""
+ for j, p in enumerate(others, 1):
+ email_str = f" <{p.email}>" if p.email else ""
+ print(f" {j}. {p.name}{email_str}")
+ idx_str = input(f" Who is 'Them'? [1-{len(others)}]: ").strip()
+ try:
+ return others[int(idx_str) - 1]
+ except (ValueError, IndexError):
+ print(" Invalid selection.")
+ return None
+
+
+def review_meeting(
+ index: int,
+ total: int,
+ meeting: dict[str, Any],
+ participants: ParsedParticipants,
+ turns: list[TranscriptTurn],
+) -> tuple[str, Participant | None]:
+ """Display meeting info and get user's import choice.
+
+ Returns (mode, them_participant) where mode is one of:
+ - "two_person": import with speaker attribution using them_participant
+ - "summary": import as a single summary message
+ - "skip": skip this meeting
+ """
+ title = meeting.get("title", "Untitled")
+ date = meeting.get("date", "")
+ creator = participants.note_creator
+ others = participants.others
+
+ me_turns = sum(1 for t in turns if t.speaker == "Me")
+ them_turns = len(turns) - me_turns
+ total_words = sum(len(t.text.split()) for t in turns)
+
+ print(f"\n{'─' * 60}")
+ print(f" [{index}/{total}] {title}")
+ print(f" Date: {date}")
+ if creator:
+ print(f" You: {creator.name} <{creator.email}>")
+ for j, p in enumerate(others, 1):
+ email_str = f" <{p.email}>" if p.email else ""
+ org_str = f" ({p.org})" if p.org else ""
+ print(f" {j}. {p.name}{email_str}{org_str}")
+
+ has_transcript = bool(meeting.get("transcript"))
+ if turns:
+ print(f" Transcript: {me_turns} Me, {them_turns} Them, ~{total_words} words")
+ if them_turns == 0:
+ print(" ** No 'Them' turns — nobody else spoke **")
+ if total_words < 30:
+ print(" ** Very short — might be empty **")
+ elif has_transcript:
+ raw = meeting["transcript"]
+ print(f" Transcript: present ({len(raw)} chars) but could not parse speaker turns")
+ print(f" Preview: {raw[:200]!r}")
+ else:
+ print(f" Content: {'summary available' if extract_summary(meeting) else 'metadata only'}")
+
+ # Two-person default: exactly one other participant with transcript
+ if len(others) == 1 and them_turns > 0:
+ them_label = others[0].name + (f" <{others[0].email}>" if others[0].email else "")
+ print(f"\n Detected: 2-person call (you + {them_label})")
+ choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
+ while choice not in ("", "s", "k"):
+ choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
+ if choice == "k":
+ return ("skip", None)
+ if choice == "s":
+ return ("summary", None)
+ return ("two_person", others[0])
+
+ # Multi-person with transcript
+ if len(others) > 1 and them_turns > 0:
+ print(f"\n {len(others)} participants")
+ choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
+ while choice not in ("", "2", "k"):
+ choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
+ if choice == "k":
+ return ("skip", None)
+ if choice == "2":
+ them = resolve_them_participant(others)
+ if them is None:
+ return ("summary", None)
+ return ("two_person", them)
+ return ("summary", None)
+
+ # No transcript or no other speakers
+ choice = input(" [Enter] summary / [k] skip: ").strip().lower()
+ while choice not in ("", "k"):
+ choice = input(" [Enter] summary / [k] skip: ").strip().lower()
+ if choice == "k":
+ return ("skip", None)
+ return ("summary", None)
# ---------------------------------------------------------------------------
@@ -402,233 +653,98 @@ async def main():
print(" Get your key at: https://app.honcho.dev/api-keys")
sys.exit(1)
- try:
- if not await authenticate():
- sys.exit(1)
+ async with httpx.AsyncClient(timeout=60.0) as http_client:
+ try:
+ access_token = await authenticate(http_client)
+ meetings = await fetch_all_meetings(http_client, access_token)
+ if not meetings:
+ sys.exit(0)
- # Fetch meeting list
- print("\nFetching meetings from Granola...")
- meetings = await list_meetings(limit=500)
- if not meetings:
- print("No meetings found.")
- sys.exit(0)
- print(f" Found {len(meetings)} meetings. Fetching content...\n")
+ from honcho import Honcho
- # Fetch content for each meeting
- for i, m in enumerate(meetings, 1):
- mid = m.get("id")
- if not mid:
- continue
+ honcho = Honcho(workspace_id="granola_test")
+ seen_peers: set[str] = set()
+ results = {"imported": 0, "skipped": 0, "failed": 0}
- transcript = await get_meeting_transcript(mid)
- if transcript:
- m["transcript"] = transcript
+ print("\n" + "=" * 60)
+ print(" Review each meeting")
+ print("=" * 60)
- try:
- m.update(await get_meeting_details(mid))
- except Exception as exc:
- print(f" Failed to fetch details for {mid}: {exc}")
+ for i, m in enumerate(meetings, 1):
+ mid = m.get("id")
+ if not mid:
+ continue
- has_t = "transcript" in m
- has_s = bool(extract_summary(m))
- label = "transcript+summary" if has_t and has_s else "transcript only" if has_t else "summary only" if has_s else "basic only"
- print(f" [{i}/{len(meetings)}] {label}: {m.get('title', 'Untitled')[:45]}")
- await asyncio.sleep(1.5) # rate limit
+ participants = parse_participants(m.get("participants", ""))
+ turns = parse_transcript_turns(m["transcript"]) if m.get("transcript") else []
- # Initialize Honcho
- from honcho import Honcho
+ mode, them = review_meeting(i, len(meetings), m, participants, turns)
- honcho = Honcho(workspace_id="granola")
- seen_peers: set[str] = set()
- results = {"imported": 0, "skipped": 0, "failed": 0}
+ if mode == "skip":
+ print(" -> Skipped")
+ results["skipped"] += 1
+ continue
- # Interactive review
- print("\n" + "=" * 60)
- print(" Review each meeting")
- print("=" * 60)
+ # Resolve creator peer
+ creator = participants.note_creator
+ me_source = (creator.email or creator.name) if creator else None
+ if not me_source:
+ print(" -> Skipped (no creator identifier)")
+ results["skipped"] += 1
+ continue
- for i, m in enumerate(meetings, 1):
- mid = m.get("id")
- if not mid:
- continue
+ me_peer_id = peer_id_from(me_source)
+ if me_peer_id not in seen_peers:
+ print(f" New peer: {me_source} ({me_peer_id})")
+ seen_peers.add(me_peer_id)
- title = m.get("title", "Untitled")
- date = m.get("date", "")
- participants = parse_participants(m.get("participants", ""))
- creator = participants["note_creator"]
- others = participants["others"]
+ try:
+ created_at = parse_date(m.get("date", ""))
+ session = honcho.session(f"meeting-{mid}")
+ metadata: dict[str, object] = {
+ "title": m.get("title", "Untitled"),
+ "date": m.get("date", ""),
+ "granola_meeting_id": mid,
+ "mode": mode,
+ }
- # Parse transcript
- turns = parse_transcript_turns(m["transcript"]) if m.get("transcript") else []
- me_turns = sum(1 for t in turns if t["speaker"] == "Me")
- them_turns = len(turns) - me_turns
- total_words = sum(len(t["text"].split()) for t in turns)
-
- # Display meeting info
- print(f"\n{'─' * 60}")
- print(f" [{i}/{len(meetings)}] {title}")
- print(f" Date: {date}")
- if creator:
- print(f" You: {creator['name']} <{creator['email']}>")
- for j, p in enumerate(others, 1):
- email_str = f" <{p['email']}>" if p["email"] else ""
- org_str = f" ({p['org']})" if p["org"] else ""
- print(f" {j}. {p['name']}{email_str}{org_str}")
-
- if turns:
- print(f" Transcript: {me_turns} Me, {them_turns} Them, ~{total_words} words")
- if them_turns == 0:
- print(" ** No 'Them' turns — nobody else spoke **")
- if total_words < 30:
- print(" ** Very short — might be empty **")
- else:
- print(f" Content: {'summary available' if extract_summary(m) else 'metadata only'}")
-
- # Ask what to do
- if len(others) == 1 and them_turns > 0:
- them_label = others[0]["name"] + (f" <{others[0]['email']}>" if others[0]["email"] else "")
- print(f"\n Detected: 2-person call (you + {them_label})")
- choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
- while choice not in ("", "s", "k"):
- choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
- elif len(others) > 1 and them_turns > 0:
- print(f"\n {len(others)} participants")
- choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
- while choice not in ("", "2", "k"):
- choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
- else:
- choice = input(" [Enter] summary / [k] skip: ").strip().lower()
- while choice not in ("", "k"):
- choice = input(" [Enter] summary / [k] skip: ").strip().lower()
-
- if choice == "k":
- print(" -> Skipped")
- results["skipped"] += 1
- continue
-
- # Resolve creator peer
- creator_email = creator["email"] if creator else None
- me_source = creator_email or (creator["name"] if creator else None)
- if not me_source:
- print(" -> Skipped (no creator identifier)")
- results["skipped"] += 1
- continue
-
- me_peer_id = peer_id_from(me_source)
- if me_peer_id not in seen_peers:
- print(f" New peer: {me_source} ({me_peer_id})")
- seen_peers.add(me_peer_id)
-
- try:
- created_at = parse_date(date)
- session = honcho.session(f"meeting-{mid}")
-
- # Two-person mode
- if (choice == "" and len(others) == 1 and them_turns > 0) or choice == "2":
- them: Participant | None = None
- if choice == "2":
- for j, p in enumerate(others, 1):
- print(f" {j}. {p['name']}" + (f" <{p['email']}>" if p["email"] else ""))
- idx_str = input(f" Who is 'Them'? [1-{len(others)}]: ").strip()
- try:
- them = others[int(idx_str) - 1]
- except (ValueError, IndexError):
- print(" Invalid — importing as summary instead.")
- choice = "s" # fall through to summary below
- else:
- them = others[0]
-
- if choice != "s" and them is not None:
- them_source: str = them["email"] or them["name"]
+ if mode == "two_person" and them is not None:
+ them_source = them.email or them.name
them_peer_id = peer_id_from(them_source)
if them_peer_id not in seen_peers:
print(f" New peer: {them_source} ({them_peer_id})")
seen_peers.add(them_peer_id)
+ import_two_person(honcho, session, me_peer_id, them_peer_id, turns, metadata, created_at)
+ else:
+ import_summary(honcho, session, me_peer_id, m, metadata, created_at)
- me_peer = honcho.peer(me_peer_id)
- them_peer = honcho.peer(them_peer_id)
+ results["imported"] += 1
- metadata: dict[str, object] = {
- "title": title, "date": date,
- "granola_meeting_id": mid, "mode": "two_person",
- }
+ except ValueError as e:
+ print(f" -> FAILED: {e}")
+ results["failed"] += 1
+ except Exception as e:
+ print(f" -> FAILED: {e}")
+ traceback.print_exc()
+ results["failed"] += 1
- # Merge consecutive same-speaker turns
- merged: list[TranscriptTurn] = []
- for t in turns:
- if merged and merged[-1]["speaker"] == t["speaker"]:
- merged[-1]["text"] += " " + t["text"]
- else:
- merged.append({"speaker": t["speaker"], "text": t["text"]})
+ # Done
+ print("\n" + "=" * 60)
+ print(" Transfer Complete!")
+ print("=" * 60)
+ print(f"\n Imported: {results['imported']}")
+ print(f" Skipped: {results['skipped']}")
+ print(f" Failed: {results['failed']}")
+ print(" Workspace: granola")
+ print(f" Peers: {sorted(seen_peers)}")
- messages: list[Any] = []
- for j, t in enumerate(merged):
- peer = me_peer if t["speaker"] == "Me" else them_peer
- content = sanitize(t["text"])
- for start in range(0, len(content), MAX_MESSAGE_LEN):
- chunk = content[start:start + MAX_MESSAGE_LEN]
- msg_meta = metadata if j == 0 and start == 0 else None
- messages.append(peer.message(chunk, metadata=msg_meta, created_at=created_at))
-
- for batch_start in range(0, len(messages), 100):
- session.add_messages(messages[batch_start:batch_start + 100])
-
- print(f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})")
- results["imported"] += 1
- continue
-
- # Summary mode
- me_peer = honcho.peer(me_peer_id)
- summary = extract_summary(m)
- if not summary:
- raw_t = m.get("transcript", "")
- try:
- parsed = json.loads(raw_t)
- summary = str(parsed.get("transcript", "")) if isinstance(parsed, dict) else raw_t
- except (json.JSONDecodeError, TypeError):
- summary = raw_t
- summary = summary or "No content available"
-
- header = f"Meeting: {title}\nDate: {date}\nParticipants: {m.get('participants', '')}\n\n"
- full = sanitize(header + summary)
-
- messages: list[Any] = []
- metadata: dict[str, object] = {"title": title, "date": date, "granola_meeting_id": mid, "mode": "summary"}
- for start in range(0, len(full), MAX_MESSAGE_LEN):
- chunk = full[start:start + MAX_MESSAGE_LEN]
- msg_meta = metadata if start == 0 else None
- messages.append(me_peer.message(chunk, metadata=msg_meta, created_at=created_at))
-
- for batch_start in range(0, len(messages), 100):
- session.add_messages(messages[batch_start:batch_start + 100])
-
- print(" -> Imported as summary")
- results["imported"] += 1
-
- except Exception as e:
- print(f" -> FAILED: {e}")
- traceback.print_exc()
- results["failed"] += 1
-
- # Done
- print("\n" + "=" * 60)
- print(" Transfer Complete!")
- print("=" * 60)
- print(f"\n Imported: {results['imported']}")
- print(f" Skipped: {results['skipped']}")
- print(f" Failed: {results['failed']}")
- print(" Workspace: granola")
- print(f" Peers: {sorted(seen_peers)}")
-
- except KeyboardInterrupt:
- print("\n\nAborted.")
- sys.exit(0)
- except Exception as e:
- print(f"\nTransfer failed: {e}")
- traceback.print_exc()
- sys.exit(1)
- finally:
- await http_client.aclose()
+ except KeyboardInterrupt:
+ print("\n\nAborted.")
+ sys.exit(0)
+ except Exception as e:
+ print(f"\nTransfer failed: {e}")
+ traceback.print_exc()
+ sys.exit(1)
if __name__ == "__main__":