mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-19 19:25:27 -04:00
Open email context for agent, email search across All Mail, cookbook serve polish
- Agent: pass the open email reader (uid/folder/account/from/subject/body
preview) on every chat submit so 'reply to this' / 'write email saying
hi' route to ui_control open_email_reply with the right UID instead of
inventing a new .md draft. Code-level enforcement (chat_routes strips
create_document + send_email when active_email is set); cross-session
active_doc_id is now trusted instead of being silently dropped.
set_active_email/clear_active_email tool-layer helpers in
tool_implementations.
- ui_control open_email_reply: optional body argument so the agent can
open-and-write in one call; envelope now forwards uid/folder/account/
body/panel through tool_output. Tool description sharpened and the
parser rejects empty bodies on reply/reply-all (forces the agent to
write rather than open an empty draft).
- Email library: search now runs against [Gmail]/All Mail when the
current folder is INBOX (archived emails surface). Whirlpool spinner
+ 'Searching…' placeholder while in flight. Each search result is
stamped with its source folder so clicks open the right email instead
of whatever shares its UID in INBOX. Search no longer re-applies the
same text pill locally (which only checks subject/from/snippet, never
body) so body-only matches don't get dropped after IMAP returns them.
Initial inbox load bumped 100→500.
- Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the
card menu and the open-reader more menu, backed by a new
/api/email/flag/{uid}?on=true|false endpoint. Flagged emails always
bubble to the top of the grid regardless of active sort.
- AI reply in doc editor: never overwrites existing draft text or the
quoted history. AI suggestion is prepended; AI-generated 'On …
wrote:' re-quotes are stripped so the original quote isn't visually
edited.
- Cookbook serve: pre-launch GPU driver / has_gpu / install / version-
floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0
etc.) before the launch chain starts. Detect 'another model already
running on this host' and offer Stop & launch (with graceful then
force tmux kill helpers, port release wait). Per-vendor deep-link
buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend
picker is now a custom dropdown with accent-coloured logos for vLLM,
SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to
package names in Dependencies. Runtime-readiness note moved inside
the panel (green when ready, red when missing) with an × dismiss.
Esc collapses the expanded card; expanded card scrolls when it
overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce
Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on
one row (Reasoning Parser auto-detected per model family).
Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto'
input — command builders read from the GPU button strip. Default
cookbook open is Download tab.
- Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by
release_date; release dates can be backfilled with the new
scripts/backfill_model_release_dates.py and recipe metadata pulled
with scripts/import_from_vllm_recipes.py against the upstream
vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped
on entries).
- Calendar: Quick add hint cycles a random Odysseus-themed example per
open (wooden horse Friday, crew muster 10am daily, council on
Ithaca, …). Typing a time like '11pm' in the event title updates
the hero clock live.
- Doc editor: email-mode Reply button (sparkle icon, accent) opens the
same Fast/Full + context popover the email reader uses; Ctrl+Alt+M
toggles markdown preview.
- Memories panel: custom sort picker with per-option icons, default
'Latest', visible Enabled/Disabled toggle text matching the section
description style.
This commit is contained in:
+79
-1
@@ -843,6 +843,7 @@ def _build_system_prompt(
|
||||
compact: bool = False,
|
||||
owner: Optional[str] = None,
|
||||
suppress_local_context: bool = False,
|
||||
active_email: Optional[Dict[str, str]] = None,
|
||||
) -> List[Dict]:
|
||||
"""Build agent system prompt, inject MCP/document context, merge consecutive system msgs."""
|
||||
global _cached_base_prompt, _cached_base_prompt_key
|
||||
@@ -1023,6 +1024,66 @@ def _build_system_prompt(
|
||||
else:
|
||||
set_active_document(None)
|
||||
|
||||
# Active email reader — frontend told us the user has an email open.
|
||||
# Inject a context block so "reply", "summarize this", "what does it say"
|
||||
# resolve to the real UID instead of the agent inventing a fresh .md
|
||||
# draft with fake headers. This is the email equivalent of _doc_message.
|
||||
_email_message = None
|
||||
if active_email and active_email.get("uid"):
|
||||
_em_uid = active_email.get("uid", "")
|
||||
_em_folder = active_email.get("folder", "INBOX")
|
||||
_em_account = active_email.get("account", "")
|
||||
_em_subject = active_email.get("subject", "") or "(no subject)"
|
||||
_em_from = active_email.get("from", "") or "(unknown sender)"
|
||||
_em_preview = (active_email.get("body_preview", "") or "").strip()
|
||||
_preview_block = f"\nBody preview:\n```\n{_em_preview[:1800]}\n```" if _em_preview else ""
|
||||
_acct_arg = f" {_em_account}" if _em_account else ""
|
||||
email_ctx = (
|
||||
f"ACTIVE EMAIL OPEN (the user has this email open in a reader window right now)\n"
|
||||
f"UID: {_em_uid}\n"
|
||||
f"Folder: {_em_folder}\n"
|
||||
f"Account: {_em_account or '(default)'}\n"
|
||||
f"From: {_em_from}\n"
|
||||
f"Subject: {_em_subject}{_preview_block}\n\n"
|
||||
f"CRITICAL DEFAULT — every request about email this turn refers to "
|
||||
f"THIS email unless the user names a DIFFERENT specific recipient "
|
||||
f"(a name, an email address, or another thread). Examples that "
|
||||
f"ALL mean reply-to-the-open-email:\n"
|
||||
f" • 'reply' / 'reply to this' / 'respond'\n"
|
||||
f" • 'write email saying X' / 'send email saying X' / 'draft something'\n"
|
||||
f" • 'tell them X' / 'say hi' / 'thanks' / 'ack' / 'lmk'\n"
|
||||
f" • 'summarize it' / 'what does it say' / 'tldr'\n"
|
||||
f" • 'forward this' / 'forward to <addr>'\n"
|
||||
f"DO NOT ASK THE USER 'who do you want to send this to?' — the "
|
||||
f"answer is ALWAYS the sender of the open email (above) unless they "
|
||||
f"named someone else. Asking that is the wrong move every time.\n\n"
|
||||
f"RULES for the open email:\n"
|
||||
f"1. DRAFT a reply (default for any 'write/send/reply/tell them' "
|
||||
f"request without a different recipient): call `ui_control` with "
|
||||
f"`action=\"open_email_reply\"` and `extra=\"{_em_uid} {_em_folder} "
|
||||
f"reply\"`. This opens the proper reply doc with To/Subject/"
|
||||
f"In-Reply-To pre-filled by the backend. The user will see and edit "
|
||||
f"it before sending. DO NOT `create_document` a markdown file with "
|
||||
f"hand-written `To:` / `Subject:` / `In-Reply-To:` headers — that "
|
||||
f"is wrong every time.\n"
|
||||
f"2. SEND a reply immediately (skip the draft): call "
|
||||
f"`reply_to_email` with the UID above. Only do this when the user "
|
||||
f"explicitly says 'send' / 'send the reply' / 'reply and send'.\n"
|
||||
f"3. READ the full body (the preview above may be truncated): "
|
||||
f"call `read_email` with the UID/folder/account above.\n"
|
||||
f"4. SUMMARIZE / answer questions about it: read it first, then "
|
||||
f"answer in chat. Don't create a document for a summary unless "
|
||||
f"the user explicitly asks for one.\n"
|
||||
f"5. Never ask the user to paste the email or 'share it with you' "
|
||||
f"— you already have its identity above and can read the full body.\n"
|
||||
f"6. The ONLY time you ask 'who to send to?' is when the user "
|
||||
f"explicitly says 'send a NEW email to someone else' or names a "
|
||||
f"recipient you can't identify. A bare 'send email saying X' = the "
|
||||
f"open email's sender.\n"
|
||||
)
|
||||
_email_message = untrusted_context_message("active email reader", email_ctx)
|
||||
_email_message["_protected"] = True
|
||||
|
||||
# Inject writing style for any email writing path. This is deliberately
|
||||
# broader than read/list: models may compose via send_email, reply_to_email,
|
||||
# or ui_control open_email_reply after the first tool round.
|
||||
@@ -1230,6 +1291,9 @@ def _build_system_prompt(
|
||||
if _doc_message:
|
||||
merged.insert(last_user_idx, _doc_message)
|
||||
last_user_idx += 1 # the document message is now at last_user_idx
|
||||
if _email_message:
|
||||
merged.insert(last_user_idx, _email_message)
|
||||
last_user_idx += 1
|
||||
if _skills_message:
|
||||
merged.insert(last_user_idx, _skills_message)
|
||||
|
||||
@@ -1712,6 +1776,7 @@ async def stream_agent_loop(
|
||||
max_tool_calls: int = 0,
|
||||
context_length: int = 0,
|
||||
active_document=None,
|
||||
active_email: Optional[Dict[str, str]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
disabled_tools: Optional[Set[str]] = None,
|
||||
owner: Optional[str] = None,
|
||||
@@ -1944,6 +2009,7 @@ async def stream_agent_loop(
|
||||
compact=_is_api_model,
|
||||
owner=owner,
|
||||
suppress_local_context=guide_only,
|
||||
active_email=active_email,
|
||||
)
|
||||
if workspace and not guide_only:
|
||||
# PREPEND (not append) so it dominates the large base prompt — appended
|
||||
@@ -2794,7 +2860,19 @@ async def stream_agent_loop(
|
||||
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
||||
if "ui_event" in result:
|
||||
tool_output_data["ui_event"] = result["ui_event"]
|
||||
for k in ("toggle_name", "state", "mode", "model", "endpoint_url", "theme_name", "colors"):
|
||||
for k in (
|
||||
"toggle_name", "state", "mode", "model", "endpoint_url",
|
||||
"theme_name", "colors",
|
||||
# ui_control open_email_reply payload — without these the
|
||||
# frontend openReplyDraft bails on undefined uid and the
|
||||
# reply window silently never opens.
|
||||
"uid", "folder", "account_id",
|
||||
# Optional pre-filled body for open_email_reply so the
|
||||
# agent can compose-and-open in one tool call.
|
||||
"body",
|
||||
# ui_control open_panel payload
|
||||
"panel",
|
||||
):
|
||||
if k in result:
|
||||
tool_output_data[k] = result[k]
|
||||
# Forward image data from generate_image tool
|
||||
|
||||
+41
-8
@@ -1287,7 +1287,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute)
|
||||
create_theme <name> <bg> <fg> <panel> <border> <accent> [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false
|
||||
open_panel <name> — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook)
|
||||
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] — Open a reply draft document for an email; does not send
|
||||
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text] — Open a reply draft document for an email; does not send. ALWAYS append the body text when the user told you what to say (one-shot draft); only omit body when the user just asked to "open a reply" without content.
|
||||
get_toggles — Return current toggle states (server-side knowledge)
|
||||
"""
|
||||
lines = content.strip().split("\n")
|
||||
@@ -1531,21 +1531,54 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
|
||||
elif action == "open_email_reply":
|
||||
reply_parts = lines[0].strip().split()
|
||||
uid = reply_parts[1].strip() if len(reply_parts) > 1 else ""
|
||||
folder = reply_parts[2].strip() if len(reply_parts) > 2 else "INBOX"
|
||||
mode = reply_parts[3].strip().lower() if len(reply_parts) > 3 else "reply"
|
||||
# Two forms supported:
|
||||
# open_email_reply <uid> [folder] [reply|reply-all|ai-reply]
|
||||
# open_email_reply <uid> [folder] [reply|reply-all|ai-reply]
|
||||
# <body text on subsequent lines or after the mode token>
|
||||
# The body text (if any) gets pre-filled into the reply draft so the
|
||||
# agent can compose-and-open in one tool call instead of opening an
|
||||
# empty draft and leaving the user to wonder what happened.
|
||||
first_line = lines[0].strip()
|
||||
parts = first_line.split(maxsplit=4)
|
||||
uid = parts[1].strip() if len(parts) > 1 else ""
|
||||
folder = parts[2].strip() if len(parts) > 2 else "INBOX"
|
||||
mode = parts[3].strip().lower() if len(parts) > 3 else "reply"
|
||||
# Body: everything on the first line after the mode token, plus any
|
||||
# subsequent lines. Allows multi-line bodies.
|
||||
inline_body = parts[4] if len(parts) > 4 else ""
|
||||
rest_lines = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""
|
||||
body = (inline_body + ("\n" + rest_lines if rest_lines else "")).strip()
|
||||
if not uid:
|
||||
return {"error": "open_email_reply needs: open_email_reply <uid> [folder] [reply|reply-all|ai-reply]"}
|
||||
return {"error": "open_email_reply needs: open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text]"}
|
||||
if mode not in ("reply", "reply-all", "ai-reply"):
|
||||
mode = "reply"
|
||||
return {
|
||||
# Body is REQUIRED for the agent path. Opening an empty draft is what
|
||||
# users do by clicking the Reply button — they don't ask the agent
|
||||
# for that. Every agent invocation of open_email_reply MUST include
|
||||
# the body. Reject empty so the agent retries with the content the
|
||||
# user asked for. Exception: ai-reply mode triggers the existing
|
||||
# AI-Reply path on the frontend which generates its own body.
|
||||
if not body and mode != "ai-reply":
|
||||
return {
|
||||
"error": (
|
||||
"open_email_reply called without body. The agent path REQUIRES a body — "
|
||||
"opening an empty draft is the wrong response when the user asked you to write. "
|
||||
"Re-call with the reply text included: "
|
||||
f"`open_email_reply {uid} {folder or 'INBOX'} {mode} <your reply text here>`. "
|
||||
"Compose the reply now based on the open email's content and the user's request, "
|
||||
"then call this tool again with the body. Do NOT call create_document instead."
|
||||
),
|
||||
}
|
||||
result = {
|
||||
"ui_event": "open_email_reply",
|
||||
"uid": uid,
|
||||
"folder": folder or "INBOX",
|
||||
"mode": mode,
|
||||
"results": f"Opening reply draft for email UID {uid}",
|
||||
"results": f"Opening reply draft for email UID {uid}" + (" with pre-filled body" if body else ""),
|
||||
}
|
||||
if body:
|
||||
result["body"] = body
|
||||
return result
|
||||
|
||||
elif action == "get_toggles":
|
||||
return {
|
||||
|
||||
@@ -61,6 +61,37 @@ def _parse_tool_args(content):
|
||||
|
||||
_active_document_id: Optional[str] = None
|
||||
_active_model: Optional[str] = None
|
||||
# When the user has an email reader window open, the frontend tells the
|
||||
# backend about it on each chat submit. We stash it here so email tools
|
||||
# (reply_to_email, read_email, mark_email) can resolve "this email" / "the
|
||||
# open one" without the agent guessing a UID. Cleared between requests by
|
||||
# chat_routes after the agent loop returns.
|
||||
_active_email_ref: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
def set_active_email(uid: Optional[str], folder: Optional[str] = None, account: Optional[str] = None,
|
||||
subject: Optional[str] = None, sender: Optional[str] = None) -> None:
|
||||
"""Stash the email currently open in the UI. None clears it."""
|
||||
global _active_email_ref
|
||||
if not uid:
|
||||
_active_email_ref = None
|
||||
return
|
||||
_active_email_ref = {
|
||||
"uid": str(uid),
|
||||
"folder": str(folder or "INBOX"),
|
||||
"account": str(account or ""),
|
||||
"subject": str(subject or ""),
|
||||
"from": str(sender or ""),
|
||||
}
|
||||
|
||||
|
||||
def get_active_email() -> Optional[Dict[str, str]]:
|
||||
return _active_email_ref
|
||||
|
||||
|
||||
def clear_active_email() -> None:
|
||||
global _active_email_ref
|
||||
_active_email_ref = None
|
||||
|
||||
|
||||
def set_active_document(doc_id: Optional[str]):
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"search_chats": "Search past session transcripts across chats.",
|
||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
||||
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
|
||||
|
||||
Reference in New Issue
Block a user