diff --git a/docs/v3/documentation/core-concepts/design-patterns.mdx b/docs/v3/documentation/core-concepts/design-patterns.mdx index 9f871cd0..d87961e0 100644 --- a/docs/v3/documentation/core-concepts/design-patterns.mdx +++ b/docs/v3/documentation/core-concepts/design-patterns.mdx @@ -138,6 +138,27 @@ Sessions define the temporal boundaries of an interaction. How you scope session - **New session** when the context resets (new conversation, new day, new topic) - **Reuse session** when context should accumulate (ongoing channel, persistent thread) +**Peer join order matters** + +Peers don't have to be added to a session all at once. You can call `session.add_peers()` multiple times as participants appear — and Honcho will take join order into account when reasoning. A peer only has context from the point they joined, not from earlier in the session. + +This is particularly useful when importing multi-participant data like email threads or chat logs, where participants join at different points: + +```python +session = honcho.session("email-thread-abc") + +# First message: just the two original participants +session.add_peers([alice, bob]) +session.add_messages([alice.message("Hey, can you review this proposal?")]) + +# Third message: charlie is CC'd in for the first time +session.add_peers([charlie]) +session.add_messages([bob.message("Looping in Charlie who handles approvals.")]) +session.add_messages([charlie.message("On it — give me a day to review.")]) +``` + +Honcho knows Charlie joined mid-thread and scopes his context accordingly. `add_peers()` is idempotent — safe to call on every message with all current participants without tracking who's already been added. The exception is if you've set custom peer configuration (e.g. `observe_me`, `observe_others`). Re-calling `add_peers()` will overwrite those settings, so you'll need to track peer configurations. + --- ## Application Patterns diff --git a/docs/v3/guides/gmail.mdx b/docs/v3/guides/gmail.mdx index ce5d82fb..eb1e9930 100644 --- a/docs/v3/guides/gmail.mdx +++ b/docs/v3/guides/gmail.mdx @@ -150,18 +150,15 @@ honcho_msgs.append(peer.message( ### Multi-Peer Sessions -Each thread's session is linked to all participants using `session.add_peers()`. This means when you query Honcho about a peer, it has context not just from their messages but from the full conversations they participated in. +Each thread's session is linked to participants using `session.add_peers()`. Rather than adding all participants upfront, the script adds peers to the session as they first appear in each message — preserving the order in which people joined the thread. ```python -session = honcho.session(session_id, metadata={ - "gmail_thread_id": tid, - "subject": subject, - "source": "gmail", - "message_count": len(msgs), -}) -session.add_peers(thread_peers) +new_peers = [p for e in new_emails if (p := ensure_peer(e))] +if new_peers: + session.add_peers(new_peers) ``` + ### Stripping Quoted Replies Email threads are full of quoted replies — each message repeats everything above it. The script strips these out so only the new content is stored per message, avoiding duplication in Honcho's memory: @@ -274,6 +271,7 @@ from googleapiclient.discovery import build from googleapiclient.errors import HttpError SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] +PEER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") def find_credentials() -> str: @@ -418,7 +416,14 @@ def parse_address_list(header: str) -> list[str]: def peer_id_from_email(email: str) -> str: """Convert email to a valid Honcho peer ID.""" - return email.replace("@", "-").replace(".", "-") + peer_id = re.sub(r"[^A-Za-z0-9_-]+", "-", email).strip("-").lower() + peer_id = re.sub(r"-{2,}", "-", peer_id) + if not peer_id: + peer_id = "unknown-peer" + + if not PEER_ID_PATTERN.fullmatch(peer_id): + raise ValueError(f"Generated peer ID is invalid: {peer_id!r}") + return peer_id def fetch_thread_messages(service, thread_id: str) -> list[dict]: @@ -508,7 +513,7 @@ def main(): # Summary total_msgs = sum(len(v) for v in all_thread_messages.values()) - print(f"\nSummary:") + print("\nSummary:") print(f" Threads: {len(all_thread_messages)}") print(f" Messages: {total_msgs}") print(f" Unique participants: {len(seen_peers)}") @@ -531,52 +536,54 @@ def main(): print(f"\nLoading into Honcho workspace '{args.workspace}'...") honcho = Honcho(workspace_id=args.workspace) - # Create peers - peers = {} - for i, (email, info) in enumerate(seen_peers.items()): - if i > 0 and i % 4 == 0: - time.sleep(1) + # Peers are created lazily and added to sessions as they first appear in each message. + # This preserves the join order so Honcho knows when each participant entered the thread. + peers = {} # email -> Honcho peer object + + def ensure_peer(email: str): + if email in peers: + return peers[email] + if email not in seen_peers: + return None + info = seen_peers[email] peers[email] = honcho.peer(info["peer_id"], metadata={ "email": email, "name": info["name"], "source": "gmail", }) print(f" Peer: {info['peer_id']}") + return peers[email] # Create sessions and messages per thread for tid, msgs in all_thread_messages.items(): subject = msgs[0]["subject"] if msgs else "No subject" session_id = f"gmail-thread-{tid}" - thread_peer_emails = set() - for m in msgs: - thread_peer_emails.add(extract_email(m["from"])) - for addr in parse_address_list(m["to"]): - thread_peer_emails.add(extract_email(addr)) - for addr in parse_address_list(m["cc"]): - thread_peer_emails.add(extract_email(addr)) - for addr in parse_address_list(m["bcc"]): - thread_peer_emails.add(extract_email(addr)) - thread_peers = [peers[e] for e in thread_peer_emails if e in peers] - session = honcho.session(session_id, metadata={ "gmail_thread_id": tid, "subject": subject, "source": "gmail", "message_count": len(msgs), }) - session.add_peers(thread_peers) - honcho_msgs = [] - for m in msgs: - email = extract_email(m["from"]) - peer = peers.get(email) + msg_count = 0 + for i, m in enumerate(msgs): + if i > 0 and i % 4 == 0: + time.sleep(1) + # Add all participants in this message — endpoint is idempotent + msg_addresses = [m["from"]] + parse_address_list(m["to"]) + parse_address_list(m["cc"]) + parse_address_list(m["bcc"]) + msg_peers = [p for a in msg_addresses if a and (p := ensure_peer(extract_email(a)))] + if msg_peers: + session.add_peers(msg_peers) + + sender_email = extract_email(m["from"]) + peer = ensure_peer(sender_email) if sender_email else None if not peer: continue content = m["body"] if m["body"] else m["snippet"] if not content: continue - honcho_msgs.append(peer.message( + session.add_messages([peer.message( content, metadata={ "gmail_id": m["id"], @@ -586,11 +593,11 @@ def main(): "labels": m["labels"], }, created_at=m["timestamp"], - )) + )]) + msg_count += 1 - if honcho_msgs: - session.add_messages(honcho_msgs) - print(f" Session {session_id}: {len(honcho_msgs)} messages — {subject[:60]}") + if msg_count: + print(f" Session {session_id}: {msg_count} messages — {subject[:60]}") print(f"\nDone! Loaded {total_msgs} messages into workspace '{args.workspace}'.") @@ -603,7 +610,7 @@ if __name__ == "__main__": - See how the Granola integration maps to common Honcho patterns. + See how the Gmail integration maps to common Honcho patterns. Source code and example script. diff --git a/docs/v3/guides/granola.mdx b/docs/v3/guides/granola.mdx index 97bbec59..2c850264 100644 --- a/docs/v3/guides/granola.mdx +++ b/docs/v3/guides/granola.mdx @@ -850,7 +850,7 @@ async def main(): from honcho import Honcho - honcho = Honcho(workspace_id="granola_test") + honcho = Honcho(workspace_id="granola") seen_peers: set[str] = set() results = {"imported": 0, "skipped": 0, "failed": 0} diff --git a/examples/gmail/honcho_gmail.py b/examples/gmail/honcho_gmail.py index 6dce60ee..d9a6dedf 100644 --- a/examples/gmail/honcho_gmail.py +++ b/examples/gmail/honcho_gmail.py @@ -295,52 +295,54 @@ def main(): print(f"\nLoading into Honcho workspace '{args.workspace}'...") honcho = Honcho(workspace_id=args.workspace) - # Create peers - peers = {} - for i, (email, info) in enumerate(seen_peers.items()): - if i > 0 and i % 4 == 0: - time.sleep(1) + # Peers are created lazily and added to sessions as they first appear in each message. + # This preserves the join order so Honcho knows when each participant entered the thread. + peers = {} # email -> Honcho peer object + + def ensure_peer(email: str): + if email in peers: + return peers[email] + if email not in seen_peers: + return None + info = seen_peers[email] peers[email] = honcho.peer(info["peer_id"], metadata={ "email": email, "name": info["name"], "source": "gmail", }) print(f" Peer: {info['peer_id']}") + return peers[email] # Create sessions and messages per thread for tid, msgs in all_thread_messages.items(): subject = msgs[0]["subject"] if msgs else "No subject" session_id = f"gmail-thread-{tid}" - thread_peer_emails = set() - for m in msgs: - thread_peer_emails.add(extract_email(m["from"])) - for addr in parse_address_list(m["to"]): - thread_peer_emails.add(extract_email(addr)) - for addr in parse_address_list(m["cc"]): - thread_peer_emails.add(extract_email(addr)) - for addr in parse_address_list(m["bcc"]): - thread_peer_emails.add(extract_email(addr)) - thread_peers = [peers[e] for e in thread_peer_emails if e in peers] - session = honcho.session(session_id, metadata={ "gmail_thread_id": tid, "subject": subject, "source": "gmail", "message_count": len(msgs), }) - session.add_peers(thread_peers) - honcho_msgs = [] - for m in msgs: - email = extract_email(m["from"]) - peer = peers.get(email) + msg_count = 0 + for i, m in enumerate(msgs): + if i > 0 and i % 4 == 0: + time.sleep(1) + # Add all participants in this message — endpoint is idempotent + msg_addresses = [m["from"]] + parse_address_list(m["to"]) + parse_address_list(m["cc"]) + parse_address_list(m["bcc"]) + msg_peers = [p for a in msg_addresses if a and (p := ensure_peer(extract_email(a)))] + if msg_peers: + session.add_peers(msg_peers) + + sender_email = extract_email(m["from"]) + peer = ensure_peer(sender_email) if sender_email else None if not peer: continue content = m["body"] if m["body"] else m["snippet"] if not content: continue - honcho_msgs.append(peer.message( + session.add_messages([peer.message( content, metadata={ "gmail_id": m["id"], @@ -350,11 +352,11 @@ def main(): "labels": m["labels"], }, created_at=m["timestamp"], - )) + )]) + msg_count += 1 - if honcho_msgs: - session.add_messages(honcho_msgs) - print(f" Session {session_id}: {len(honcho_msgs)} messages — {subject[:60]}") + if msg_count: + print(f" Session {session_id}: {msg_count} messages — {subject[:60]}") print(f"\nDone! Loaded {total_msgs} messages into workspace '{args.workspace}'.")