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:
pewdiepie-archdaemon
2026-06-04 08:27:26 +09:00
parent 6e80d0de08
commit 089246614d
17 changed files with 1301 additions and 387 deletions

View File

@@ -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",

View File

@@ -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:

View File

@@ -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)"},