feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude, setup_claude_routes + integrations/claude/ skill bundle. Wired in app.py alongside the existing Codex integration; same scope-gated /api/codex/* backend; agent form has new description so users know it's setup for an external CLI, not an agent streamed inside Odysseus. - Remove mark_email_boundaries action: not good enough yet. Stripped from task UI, scheduler defaults, registry, tool schema, clear-cache route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their task_runs auto-purge on startup. - Cookbook download reliability: "Reconnect" fix button in the crash diagnosis runs _reconnectTask after probing has-session. 30s confirm window before marking a download "done" — kills the Finished/Downloading flicker when tmux briefly drops between captures. - Mobile UX: tap anywhere on a note card body opens the editor; Update button morphs to Archive when no text was edited; bell icon accent-colored; chip-trashing notif pills fade so only the icon rotates into the trash zone. - Settings integrations: SVG-per-provider in email + API preset dropdowns, custom drop-up-aware menus, accent sub-header icons (IMAP/SMTP), consistent card styling between list + edit, contacts Edit/Delete icons, agent form description copy.
This commit is contained in:
@@ -760,201 +760,6 @@ async def action_extract_email_events(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def action_mark_email_boundaries(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""LLM-based signature / quoted-reply boundary detection. For each new
|
||||
inbox email that we haven't analyzed yet, ask the model to return char
|
||||
offsets where the signature and quoted-reply start. Cache the offsets
|
||||
keyed by Message-ID — once cached, the renderer uses them directly with
|
||||
no further LLM calls. Caps at 30 emails per pass to keep cost bounded.
|
||||
"""
|
||||
try:
|
||||
import sqlite3 as _sql3
|
||||
import json as _json
|
||||
import re as _re
|
||||
import email as _email_mod
|
||||
import asyncio as _aio
|
||||
from datetime import datetime as _dt
|
||||
from routes.email_helpers import _imap_connect, _decode_header, SCHEDULED_DB
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
# Pull recent inbox UIDs + Message-IDs directly via IMAP (the
|
||||
# nested helpers in email_routes aren't importable, and this keeps
|
||||
# the action self-contained).
|
||||
def _pull_recent():
|
||||
results = []
|
||||
conn = _imap_connect(None)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
status, data = conn.search(None, "ALL")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return results
|
||||
uids = data[0].split()[-50:][::-1] # newest 50
|
||||
for uid in uids:
|
||||
try:
|
||||
st, msg_data = conn.fetch(uid, "(RFC822.HEADER)")
|
||||
if st != "OK" or not msg_data or not msg_data[0]:
|
||||
continue
|
||||
raw = msg_data[0][1] if isinstance(msg_data[0], tuple) else None
|
||||
if not raw:
|
||||
continue
|
||||
msg = _email_mod.message_from_bytes(raw)
|
||||
results.append({
|
||||
"uid": uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
"message_id": (msg.get("Message-ID") or "").strip(),
|
||||
"subject": _decode_header(msg.get("Subject", "")),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
return results
|
||||
|
||||
mails = await _aio.to_thread(_pull_recent)
|
||||
if not mails:
|
||||
raise TaskNoop("no emails to analyze")
|
||||
|
||||
url, model, headers = resolve_endpoint("utility")
|
||||
if not url or not model:
|
||||
url, model, headers = resolve_endpoint("default")
|
||||
if not url or not model:
|
||||
return "No LLM endpoint available", False
|
||||
|
||||
c = _sql3.connect(SCHEDULED_DB)
|
||||
already = {r[0] for r in c.execute(
|
||||
"SELECT message_id FROM email_boundaries"
|
||||
).fetchall()}
|
||||
c.close()
|
||||
|
||||
analyzed = 0
|
||||
skipped = 0
|
||||
for em in mails[:30]:
|
||||
mid = (em.get("message_id") or "").strip()
|
||||
if not mid or mid in already:
|
||||
skipped += 1
|
||||
continue
|
||||
uid = em.get("uid")
|
||||
if not uid:
|
||||
continue
|
||||
def _fetch_body(_uid):
|
||||
conn = _imap_connect(None)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
st, data = conn.fetch(_uid, "(BODY.PEEK[TEXT])")
|
||||
if st != "OK" or not data or not data[0]:
|
||||
return ""
|
||||
raw = data[0][1] if isinstance(data[0], tuple) else None
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return str(raw)
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
try:
|
||||
body = (await _aio.to_thread(_fetch_body, str(uid))).strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"boundary detection: IMAP fetch failed for uid={uid} mid={mid}: {e}")
|
||||
continue
|
||||
if not body or len(body) < 100:
|
||||
continue
|
||||
# Truncate very long bodies — boundaries usually live in the
|
||||
# first few KB of plain text.
|
||||
truncated = body[:8000]
|
||||
|
||||
prompt = (
|
||||
"Identify where the signature and the quoted-reply start in "
|
||||
"this email body. Return ONLY raw JSON, no prose. Schema:\n"
|
||||
'{"sig_start": <int>, "quote_start": <int>}\n\n'
|
||||
"Rules:\n"
|
||||
"- sig_start = char offset where the sender's signature block "
|
||||
"begins (closing phrase like 'Best regards' / 'Mit freundlichen' / "
|
||||
"'Med vänliga' / contact details / disclaimer / job title block). "
|
||||
"Use -1 if none.\n"
|
||||
"- quote_start = char offset where any quoted-reply / forwarded "
|
||||
"thread begins (lines like 'On <date>, <name> wrote:', "
|
||||
"'From: ... Sent: ... Subject:' in any language — German 'Von:', "
|
||||
"French 'De :', Spanish 'De:', etc.). Use -1 if none.\n"
|
||||
"- Both offsets are byte/char positions in the input string starting "
|
||||
"from 0. The signature/quote should INCLUDE the marker line itself.\n"
|
||||
"- If both exist, sig_start is normally before quote_start (sig of "
|
||||
"the current message, then quoted thread underneath).\n\n"
|
||||
f"BODY (length={len(truncated)}):\n{truncated}"
|
||||
)
|
||||
try:
|
||||
raw = await llm_call_async(
|
||||
url=url, model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0, max_tokens=200,
|
||||
headers=headers, timeout=60,
|
||||
)
|
||||
from src.text_helpers import strip_think as _st
|
||||
raw = _st(raw or "", prose=False, prompt_echo=False)
|
||||
raw = _re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=_re.MULTILINE).strip()
|
||||
# Balanced-brace match: handles {"sig_start": 10, "info": {}}
|
||||
# which the previous [^{}] class would have broken on.
|
||||
start = raw.find("{")
|
||||
m_text = None
|
||||
if start != -1:
|
||||
depth = 0
|
||||
for i in range(start, len(raw)):
|
||||
if raw[i] == "{":
|
||||
depth += 1
|
||||
elif raw[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
m_text = raw[start:i + 1]
|
||||
break
|
||||
if not m_text:
|
||||
logger.warning(f"boundary detection: no JSON object in LLM response for mid={mid}: {raw[:200]!r}")
|
||||
continue
|
||||
parsed = _json.loads(m_text)
|
||||
sig = int(parsed.get("sig_start", -1))
|
||||
quote = int(parsed.get("quote_start", -1))
|
||||
except Exception as e:
|
||||
logger.warning(f"boundary detection failed for mid={mid}: {e}")
|
||||
continue
|
||||
|
||||
# Also pre-parse the thread tree so the client never has to.
|
||||
try:
|
||||
from src.email_thread_parser import parse_thread, THREAD_PARSER_VERSION
|
||||
# The boundary loop only has the plaintext body; parse_thread
|
||||
# also accepts None for HTML so this is safe.
|
||||
turns = parse_thread(None, body)
|
||||
turns_json = (
|
||||
_json.dumps({"v": THREAD_PARSER_VERSION, "turns": turns})
|
||||
if turns else None
|
||||
)
|
||||
except Exception as _pe:
|
||||
logger.debug(f"thread parse failed for {mid}: {_pe}")
|
||||
turns_json = None
|
||||
|
||||
try:
|
||||
c = _sql3.connect(SCHEDULED_DB)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO email_boundaries "
|
||||
"(message_id, uid, folder, sig_start, quote_start, model_used, created_at, turns_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(mid, str(uid), "INBOX", sig, quote, model, _dt.utcnow().isoformat(), turns_json),
|
||||
)
|
||||
c.commit()
|
||||
c.close()
|
||||
analyzed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"could not cache boundaries for {mid}: {e}")
|
||||
|
||||
if analyzed == 0:
|
||||
# All recent emails already had boundaries cached — nothing new
|
||||
# to do, don't pollute Activity.
|
||||
raise TaskNoop(f"boundaries already cached for {skipped} email(s)")
|
||||
return f"Marked boundaries: {analyzed} new, {skipped} cached", True
|
||||
except Exception as e:
|
||||
logger.error(f"mark_email_boundaries failed: {e}")
|
||||
return str(e), False
|
||||
|
||||
|
||||
# Sender local-parts (matched exactly or by prefix) whose mail never carries a
|
||||
# personal signature worth learning. These compare against the local-part
|
||||
@@ -2205,7 +2010,6 @@ BUILTIN_ACTIONS = {
|
||||
# ping_events removed from the user-facing registry. Calendar reminders
|
||||
# are represented as Notes, so note pings are the single dispatch path.
|
||||
"daily_brief": action_daily_brief,
|
||||
"mark_email_boundaries": action_mark_email_boundaries,
|
||||
"learn_sender_signatures": action_learn_sender_signatures,
|
||||
"ssh_command": action_ssh_command,
|
||||
"run_script": action_run_script,
|
||||
@@ -2227,7 +2031,6 @@ BUILTIN_ACTION_INFO = {
|
||||
"extract_email_events": "Scan emails for booking/meeting confirmations and auto-add to calendar",
|
||||
"classify_events": "Tag upcoming events with importance (low/normal/high/critical) and type (work/health/travel/etc.); colors them too",
|
||||
"daily_brief": "Build a morning digest: today's calendar, unread email count + top senders, active todos",
|
||||
"mark_email_boundaries": "LLM-detect signature & quoted-reply offsets in new emails; cached so future renders fold without further LLM calls",
|
||||
"learn_sender_signatures": "LLM learns each sender's signature from 3+ of their recent emails; cached per address so future renders fold sigs reliably without heuristics",
|
||||
"ssh_command": "Run a shell command on a local or remote host",
|
||||
"run_script": "Run a script locally or on ODYSSEUS_SCRIPT_HOST",
|
||||
|
||||
@@ -211,7 +211,6 @@ HOUSEKEEPING_DEFAULTS = {
|
||||
"draft_email_replies": {"name": "Email AI Auto Reply", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Tidy Email (Replies)", "AI Auto Reply"]},
|
||||
"extract_email_events": {"name": "Email Calendar Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */1 * * *", "ship_paused": True, "legacy_names": ["Email → Calendar Events"]},
|
||||
"classify_events": {"name": "Calendar Classify Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 6,18 * * *", "ship_paused": True, "legacy_names": ["Classify Calendar Events"]},
|
||||
"mark_email_boundaries": {"name": "Email Mark Boundaries", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Mark Email Boundaries"]},
|
||||
"check_email_urgency": {"name": "Email Tags", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 * * * *", "ship_paused": True, "old_cron_expressions": ["*/15 * * * *"], "legacy_names": ["Email Triage", "Urgent Email"]},
|
||||
"audit_skills": {"name": "Skills Audit", "trigger_type": "event", "trigger_event": "skill_added", "trigger_count": 5, "schedule": None, "scheduled_time": None, "cron_expression": None, "legacy_names": ["Audit Skills"]},
|
||||
}
|
||||
@@ -219,6 +218,7 @@ HOUSEKEEPING_DEFAULTS = {
|
||||
RETIRED_HOUSEKEEPING_ACTIONS = frozenset({
|
||||
"tidy_calendar",
|
||||
"tidy_email_inbox",
|
||||
"mark_email_boundaries",
|
||||
})
|
||||
|
||||
|
||||
@@ -944,7 +944,6 @@ class TaskScheduler:
|
||||
# Activity log + reminder email already carry everything the user needs.
|
||||
_SILENT_ACTIONS = frozenset({
|
||||
"check_email_urgency",
|
||||
"mark_email_boundaries",
|
||||
"learn_sender_signatures",
|
||||
"summarize_emails",
|
||||
"draft_email_replies",
|
||||
@@ -963,7 +962,6 @@ class TaskScheduler:
|
||||
"draft_email_replies",
|
||||
"extract_email_events",
|
||||
"classify_events",
|
||||
"mark_email_boundaries",
|
||||
"learn_sender_signatures",
|
||||
"check_email_urgency",
|
||||
"test_skills",
|
||||
@@ -1946,11 +1944,30 @@ class TaskScheduler:
|
||||
task.task_type = "action"
|
||||
task.action = action
|
||||
|
||||
from core.database import TaskRun
|
||||
retired_ids = [
|
||||
row[0] for row in db.query(ScheduledTask.id).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(RETIRED_HOUSEKEEPING_ACTIONS)),
|
||||
).all()
|
||||
]
|
||||
if retired_ids:
|
||||
db.query(TaskRun).filter(TaskRun.task_id.in_(retired_ids)).delete(synchronize_session=False)
|
||||
retired_count = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(RETIRED_HOUSEKEEPING_ACTIONS)),
|
||||
).delete(synchronize_session=False)
|
||||
# Sweep orphan TaskRun rows (parent task deleted previously) so
|
||||
# retired actions stop showing in Activity. Only runs when at least
|
||||
# one live task exists — avoids wiping run history on a fresh DB.
|
||||
try:
|
||||
live_ids = {row[0] for row in db.query(ScheduledTask.id).all()}
|
||||
if live_ids:
|
||||
db.query(TaskRun).filter(~TaskRun.task_id.in_(list(live_ids))).delete(synchronize_session=False)
|
||||
except Exception:
|
||||
pass
|
||||
existing_actions = {
|
||||
row[0] for row in db.query(ScheduledTask.action).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
@@ -2088,11 +2105,13 @@ class TaskScheduler:
|
||||
db.add(task)
|
||||
seeded.append(action)
|
||||
if seeded or renamed or removed_dupes or retired_count:
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s",
|
||||
owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count,
|
||||
)
|
||||
# Always commit — the orphan-run sweep above may have produced
|
||||
# pending deletes even when no defaults changed.
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create default tasks: {e}")
|
||||
finally:
|
||||
|
||||
@@ -399,7 +399,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"action_name": {"type": "string", "enum": [
|
||||
"tidy_sessions", "tidy_documents", "consolidate_memory", "tidy_research",
|
||||
"summarize_emails", "draft_email_replies", "extract_email_events",
|
||||
"classify_events", "mark_email_boundaries", "learn_sender_signatures",
|
||||
"classify_events", "learn_sender_signatures",
|
||||
"test_skills", "audit_skills", "check_email_urgency"
|
||||
],
|
||||
"description": "Built-in action (for task_type=action)"},
|
||||
|
||||
Reference in New Issue
Block a user