diff --git a/app.py b/app.py index 3ac883a..7a00722 100644 --- a/app.py +++ b/app.py @@ -537,7 +537,8 @@ app.include_router(setup_admin_wipe_routes(session_manager)) # Memory from routes.memory_routes import setup_memory_routes -app.include_router(setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)) +memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector) +app.include_router(memory_router) from routes.skills_routes import setup_skills_routes app.include_router(setup_skills_routes(skills_manager)) @@ -600,7 +601,8 @@ logger.info("STT service initialized (provider managed via settings)") # Documents (artifacts/canvas) from routes.document_routes import setup_document_routes -app.include_router(setup_document_routes(session_manager, upload_handler)) +document_router = setup_document_routes(session_manager, upload_handler) +app.include_router(document_router) # Signatures (reusable image stamps) from routes.signature_routes import setup_signature_routes @@ -627,7 +629,8 @@ app.include_router(setup_assistant_routes(task_scheduler)) # Calendar (CalDAV) from routes.calendar_routes import setup_calendar_routes -app.include_router(setup_calendar_routes()) +calendar_router = setup_calendar_routes() +app.include_router(calendar_router) # Shell (user-facing command execution) from routes.shell_routes import setup_shell_routes @@ -698,8 +701,14 @@ app.include_router(email_router) # Codex sessions can only touch the data the user explicitly allowed. Mounted # AFTER email so the codex_routes can borrow the email router for shared # search/threading helpers. -from routes.codex_routes import setup_codex_routes -app.include_router(setup_codex_routes(email_router=email_router)) +from routes.codex_routes import setup_codex_routes, setup_claude_routes +app.include_router(setup_codex_routes( + email_router=email_router, + memory_router=memory_router, + calendar_router=calendar_router, + document_router=document_router, +)) +app.include_router(setup_claude_routes()) from routes.vault_routes import setup_vault_routes app.include_router(setup_vault_routes()) diff --git a/integrations/claude/README.md b/integrations/claude/README.md new file mode 100644 index 0000000..e2671f8 --- /dev/null +++ b/integrations/claude/README.md @@ -0,0 +1,36 @@ +# Odysseus Claude Code Integration + +This directory contains the Claude Code skill bundle for Odysseus. + +## User Flow + +1. Open Odysseus Settings > Integrations. +2. Add a Claude Agent. +3. Copy the full setup commands shown after the generated token. +4. Toggle the tools Claude is allowed to use. +5. Configure the terminal Claude Code session: + +```bash +export ODYSSEUS_URL=http://your-odysseus-host:7000 +export ODYSSEUS_API_TOKEN=ody_generated_token +mkdir -p ~/.claude +curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip +python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/ +``` + +Claude Code auto-loads anything under `~/.claude/skills/`, so the `odysseus` skill is +available in any session that has `ODYSSEUS_URL` and `ODYSSEUS_API_TOKEN` in its +environment. + +## What's in the bundle + +- `skills/odysseus/SKILL.md` — the skill definition Claude Code reads. +- `skills/odysseus/scripts/odysseus_api.py` — small helper that calls the scoped + `/api/codex/*` endpoints (these are the canonical scope-gated agent API; the + `codex` path is historic and shared by all agent integrations). + +## Scope enforcement + +The token is scope-gated. Every tool surface is checked server-side in Odysseus, +so even if Claude tries to call a forbidden endpoint, it gets `403` until the +user enables the matching toggle in Settings > Integrations > Claude Agent. diff --git a/integrations/claude/skills/odysseus/SKILL.md b/integrations/claude/skills/odysseus/SKILL.md new file mode 100644 index 0000000..3877f9c --- /dev/null +++ b/integrations/claude/skills/odysseus/SKILL.md @@ -0,0 +1,110 @@ +--- +name: odysseus +description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN. +--- + +# Odysseus + +Use this skill when a user asks to interact with Odysseus from Claude Code. + +## Configuration + +Expect these environment variables: + +- `ODYSSEUS_URL`: Base URL for the user's Odysseus instance, for example `http://127.0.0.1:7000`. +- `ODYSSEUS_API_TOKEN`: Scoped API token created in Odysseus Settings > Integrations > Add Integration > Claude Agent. + +If either value is missing, do not guess credentials. Tell the user to create a Claude Agent token in Odysseus Settings and expose both values to the terminal session. + +## When to use what + +- **Reminder ("remind me at 5pm to do X")** → TODO with `due_date`. The due_date IS the reminder — it fires a notification automatically via the user's configured channel (browser/email/ntfy). **Do NOT create a calendar event for a reminder.** Creating a calendar event named "Reminder" does NOT trigger a notification — it's just a time block on the calendar. +- **Calendar event ("meeting at 3pm", "dentist Tuesday 10am")** → calendar event. Use for scheduled time blocks, meetings, appointments, recurring schedules. These show up on the calendar grid; reminders for them are configured separately in Odysseus settings. +- **Note / freeform info ("note that the wifi password is ...")** → memory or todo without a due_date (depending on whether it's a fact about the user or an action item). +- **Persistent fact / preference about the user** → memory. + +If the user says "reminder" + a time, default to TODO with due_date. Only switch to calendar if the user explicitly says "calendar", "event", "meeting", "appointment", or describes a time *range*. + +## Safety + +- All Odysseus data access MUST go through the scoped HTTP API under `/api/codex/*` (the canonical scope-gated agent API, shared by all agent integrations). +- Check `/api/codex/capabilities` before using a tool surface. +- Treat `403` as an intentional Settings restriction. Do not work around it. +- Do not use SSH, Docker, direct Python imports, SQLite queries, MCP internals, browser cookies, or local files to read/write Odysseus user data. +- Do not call helpers like `do_manage_notes`, email MCP internals, or database sessions directly for user data, even if shell access exists. +- Never send email directly unless the user explicitly asks to send and the token has a send-capable scope. +- Keep actions scoped to the token owner. + +## Todos + +The scoped agent API supports todos/checklists: + +- `GET /api/codex/todos` +- `POST /api/codex/todos` + +Use the bundled helper script when available: + +```bash +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py todos list +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py todos add "Follow up" +``` + +Supported todo actions are `list`, `add`, `update`, `delete`, and `toggle_item`. + +**Reminders (todos with a due date)** — the backend parses natural language. Send `due_date` in the body via the generic POST so the time becomes a structured reminder, NOT a literal substring inside the title. The `todos add TITLE` shortcut only sets the title, so use the POST form for anything with a time: + +```bash +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/todos '{"action":"add","title":"Call dentist","due_date":"tomorrow at 5pm"}' +``` + +The backend accepts both ISO timestamps and natural language like `"tomorrow 5pm"`, `"next Monday 9am"`, `"in 2 hours"`. It anchors to the user's timezone. + +## Email + +The scoped agent API supports email reads: + +- `GET /api/codex/emails?folder=INBOX&limit=10&offset=0&filter=all` +- `GET /api/codex/emails/{uid}?folder=INBOX` + +Use the bundled helper script when available: + +```bash +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py emails list 5 +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py emails read UID +``` + +If `/api/codex/capabilities` does not show `email.read: true`, do not inspect email. Ask the user to enable Email read in the Claude Agent settings. + +## Memory + +- `GET /api/codex/memory` — list memories for the token owner. +- `POST /api/codex/memory` — body `{"text": "...", "category": "fact", "source": "user", "session_id": null}`. Requires `memory:write`. +- `DELETE /api/codex/memory/{memory_id}` — remove a memory entry. Requires `memory:write`. + +```bash +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/memory +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory '{"text":"User prefers SI units","category":"preference"}' +``` + +## Calendar + +- `GET /api/codex/calendar/events?start=ISO&end=ISO` — list events in window. +- `POST /api/codex/calendar/events` — body matches `EventCreate` (`summary`, `dtstart`, `dtend`, `all_day`, `description`, `location`, `calendar_href`, `rrule`, `color`). Requires `calendar:write`. +- `DELETE /api/codex/calendar/events/{uid}` — delete event by uid (the value returned in the POST response). Requires `calendar:write`. + +## Documents + +- `GET /api/codex/documents?search=...&limit=50` — paginated library. +- `GET /api/codex/documents/{doc_id}` — fetch one document. +- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. +- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. + +## Email draft + send + +- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`). +- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction. + +## Forbidden Bypass Pattern + +If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Claude Agent tool toggle instead. diff --git a/integrations/claude/skills/odysseus/scripts/odysseus_api.py b/integrations/claude/skills/odysseus/scripts/odysseus_api.py new file mode 100755 index 0000000..5fdd632 --- /dev/null +++ b/integrations/claude/skills/odysseus/scripts/odysseus_api.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Small Odysseus scoped API helper for Codex terminal sessions.""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request + + +def _usage() -> int: + print("usage:", file=sys.stderr) + print(" odysseus_api.py capabilities", file=sys.stderr) + print(" odysseus_api.py todos list", file=sys.stderr) + print(" odysseus_api.py todos add TITLE", file=sys.stderr) + print(" odysseus_api.py emails list [limit]", file=sys.stderr) + print(" odysseus_api.py emails read UID", file=sys.stderr) + print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr) + return 2 + + +def _config() -> tuple[str, str] | None: + base_url = os.environ.get("ODYSSEUS_URL", "").strip().rstrip("/") + token = os.environ.get("ODYSSEUS_API_TOKEN", "").strip() + missing = [] + if not base_url: + missing.append("ODYSSEUS_URL") + if not token: + missing.append("ODYSSEUS_API_TOKEN") + if missing: + print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr) + return None + return base_url, token + + +def main() -> int: + if len(sys.argv) < 2: + return _usage() + + command = sys.argv[1].lower() + if command == "capabilities": + method = "GET" + path = "/api/codex/capabilities" + body = None + elif command == "todos": + if len(sys.argv) < 3: + return _usage() + action = sys.argv[2].lower() + path = "/api/codex/todos" + if action == "list": + method = "GET" + body = None + elif action == "add" and len(sys.argv) >= 4: + method = "POST" + body = json.dumps({"action": "add", "title": " ".join(sys.argv[3:])}) + else: + return _usage() + elif command == "emails": + if len(sys.argv) < 3: + return _usage() + action = sys.argv[2].lower() + if action == "list": + method = "GET" + limit = sys.argv[3] if len(sys.argv) >= 4 else "10" + path = f"/api/codex/emails?folder=INBOX&limit={limit}&offset=0&filter=all" + body = None + elif action == "read" and len(sys.argv) >= 4: + method = "GET" + path = f"/api/codex/emails/{sys.argv[3]}" + body = None + else: + return _usage() + else: + if len(sys.argv) < 3: + return _usage() + method = sys.argv[1].upper() + path = sys.argv[2] + body = sys.argv[3] if len(sys.argv) > 3 else None + + if not path.startswith("/"): + path = "/" + path + if not path.startswith("/api/codex/"): + print("refusing non-/api/codex path; use scoped Odysseus integration endpoints only", file=sys.stderr) + return 2 + + config = _config() + if config is None: + return 2 + base_url, token = config + + data = None + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {token}", + } + if body is not None: + try: + parsed = json.loads(body) + except json.JSONDecodeError as exc: + print(f"invalid json body: {exc}", file=sys.stderr) + return 2 + data = json.dumps(parsed).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + print(resp.read().decode("utf-8")) + return 0 + except urllib.error.HTTPError as exc: + text = exc.read().decode("utf-8", errors="replace") + print(text or f"HTTP {exc.code}", file=sys.stderr) + return 1 + except OSError as exc: + print(f"request failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/codex/skills/odysseus/SKILL.md b/integrations/codex/skills/odysseus/SKILL.md index cf87248..1e2be01 100644 --- a/integrations/codex/skills/odysseus/SKILL.md +++ b/integrations/codex/skills/odysseus/SKILL.md @@ -16,6 +16,15 @@ Expect these environment variables: If either value is missing, do not guess credentials. Tell the user to create a Codex Agent token in Odysseus Settings and expose both values to the terminal session. +## When to use what + +- **Reminder ("remind me at 5pm to do X")** → TODO with `due_date`. The due_date IS the reminder — it fires a notification automatically via the user's configured channel (browser/email/ntfy). **Do NOT create a calendar event for a reminder.** Creating a calendar event named "Reminder" does NOT trigger a notification — it's just a time block on the calendar. +- **Calendar event ("meeting at 3pm", "dentist Tuesday 10am")** → calendar event. Use for scheduled time blocks, meetings, appointments, recurring schedules. These show up on the calendar grid; reminders for them are configured separately in Odysseus settings. +- **Note / freeform info ("note that the wifi password is ...")** → memory or todo without a due_date (depending on whether it's a fact about the user or an action item). +- **Persistent fact / preference about the user** → memory. + +If the user says "reminder" + a time, default to TODO with due_date. Only switch to calendar if the user explicitly says "calendar", "event", "meeting", "appointment", or describes a time *range*. + ## Safety - All Odysseus data access MUST go through the scoped HTTP API under `/api/codex/*`. @@ -43,6 +52,14 @@ python3 integrations/codex/scripts/odysseus_api.py todos add "Follow up" Supported todo actions are `list`, `add`, `update`, `delete`, and `toggle_item`. +**Reminders (todos with a due date)** — the backend parses natural language. Send `due_date` in the body via the generic POST so the time becomes a structured reminder, NOT a literal substring inside the title. The `todos add TITLE` shortcut only sets the title, so use the POST form for anything with a time: + +```bash +python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/todos '{"action":"add","title":"Call dentist","due_date":"tomorrow at 5pm"}' +``` + +The backend accepts both ISO timestamps and natural language like `"tomorrow 5pm"`, `"next Monday 9am"`, `"in 2 hours"`. It anchors to the user's timezone. + ## Email The Codex API supports scoped email reads: @@ -59,6 +76,35 @@ python3 integrations/codex/scripts/odysseus_api.py emails read UID If `/api/codex/capabilities` does not show `email.read: true`, do not inspect email. Ask the user to enable Email read in the Codex Agent settings. +## Memory + +- `GET /api/codex/memory` — list memories for the token owner. +- `POST /api/codex/memory` — body `{"text": "...", "category": "fact", "source": "user", "session_id": null}`. Requires `memory:write`. +- `DELETE /api/codex/memory/{memory_id}` — remove a memory entry. Requires `memory:write`. + +```bash +python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/memory +python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"text":"User prefers SI units","category":"preference"}' +``` + +## Calendar + +- `GET /api/codex/calendar/events?start=ISO&end=ISO` — list events in window. +- `POST /api/codex/calendar/events` — body matches `EventCreate` (`summary`, `dtstart`, `dtend`, `all_day`, `description`, `location`, `calendar_href`, `rrule`, `color`). Requires `calendar:write`. +- `DELETE /api/codex/calendar/events/{uid}` — delete event by uid (the value returned in the POST response). Requires `calendar:write`. + +## Documents + +- `GET /api/codex/documents?search=...&limit=50` — paginated library. +- `GET /api/codex/documents/{doc_id}` — fetch one document. +- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. +- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. + +## Email draft + send + +- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`). +- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction. + ## Forbidden Bypass Pattern If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Codex Agent tool toggle instead. diff --git a/routes/codex_routes.py b/routes/codex_routes.py index 35e6570..8c59ee5 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -5,13 +5,14 @@ reuse existing Odysseus helpers and enforce API-token scopes before touching user data. """ +import asyncio import json import zipfile from io import BytesIO from pathlib import Path from typing import Any -from fastapi import APIRouter, Body, HTTPException, Request +from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request from fastapi.responses import StreamingResponse from src.auth_helpers import require_user @@ -21,9 +22,33 @@ from src.tool_implementations import do_manage_notes TODO_READ_SCOPES = {"todos:read", "todos:write"} TODO_WRITE_SCOPES = {"todos:write"} EMAIL_READ_SCOPES = {"email:read", "email:draft", "email:send"} +EMAIL_DRAFT_SCOPES = {"email:draft", "email:send"} +EMAIL_SEND_SCOPES = {"email:send"} +MEMORY_READ_SCOPES = {"memory:read", "memory:write"} +MEMORY_WRITE_SCOPES = {"memory:write"} +CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"} +CALENDAR_WRITE_SCOPES = {"calendar:write"} +DOCS_READ_SCOPES = {"documents:read", "documents:write"} +DOCS_WRITE_SCOPES = {"documents:write"} WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"} +async def _as_owner(request: Request, owner: str, fn, *args, **kwargs): + """Run an existing route handler with request.state.current_user temporarily + set to ``owner`` so its internal get_current_user/require_user calls see + the scope-gated owner (not the "api" pseudo-user the bearer middleware sets). + Restores the original value when done. Works for sync and async handlers.""" + orig = getattr(request.state, "current_user", None) + request.state.current_user = owner + try: + result = fn(*args, **kwargs) + if asyncio.iscoroutine(result): + result = await result + return result + finally: + request.state.current_user = orig + + def _scope_owner(request: Request, allowed: set[str]) -> str: """Return the data owner if the caller is allowed for this Codex action.""" if getattr(request.state, "api_token", False): @@ -47,30 +72,64 @@ def _find_endpoint(router: APIRouter | None, method: str, path: str): return None -def setup_codex_routes(email_router: APIRouter | None = None) -> APIRouter: +def setup_codex_routes( + email_router: APIRouter | None = None, + memory_router: APIRouter | None = None, + calendar_router: APIRouter | None = None, + document_router: APIRouter | None = None, +) -> APIRouter: router = APIRouter(prefix="/api/codex", tags=["codex"]) email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list") email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}") + email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send") + email_draft_endpoint = _find_endpoint(email_router, "POST", "/api/email/draft") + memory_list_endpoint = _find_endpoint(memory_router, "GET", "/api/memory") + memory_add_endpoint = _find_endpoint(memory_router, "POST", "/api/memory/add") + calendar_list_events = _find_endpoint(calendar_router, "GET", "/api/calendar/events") + calendar_create_event = _find_endpoint(calendar_router, "POST", "/api/calendar/events") + documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library") + documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}") + documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document") @router.get("/capabilities") def capabilities(request: Request): token_scopes = set(getattr(request.state, "api_token_scopes", []) or []) has_token = bool(getattr(request.state, "api_token", False)) + def scoped(allowed): + return bool(token_scopes.intersection(allowed)) if has_token else True return { "integration": "codex", "token_scopes": sorted(token_scopes), "tools": { "todos": { - "read": bool(token_scopes.intersection(TODO_READ_SCOPES)) if has_token else True, - "write": bool(token_scopes.intersection(TODO_WRITE_SCOPES)) if has_token else True, + "read": scoped(TODO_READ_SCOPES), + "write": scoped(TODO_WRITE_SCOPES), "actions": ["list", "add", "update", "delete", "toggle_item"], }, "email": { - "read": bool(token_scopes.intersection(EMAIL_READ_SCOPES)) if has_token else True, - "draft": "email:draft" in token_scopes if has_token else True, - "send": "email:send" in token_scopes if has_token else True, - "actions": ["list", "read"], - } + "read": scoped(EMAIL_READ_SCOPES), + "draft": scoped(EMAIL_DRAFT_SCOPES), + "send": scoped(EMAIL_SEND_SCOPES), + "actions": ["list", "read", "draft", "send"], + }, + "memory": { + "read": scoped(MEMORY_READ_SCOPES), + "write": scoped(MEMORY_WRITE_SCOPES), + "actions": ["list", "add", "delete"], + "available": memory_list_endpoint is not None, + }, + "calendar": { + "read": scoped(CALENDAR_READ_SCOPES), + "write": scoped(CALENDAR_WRITE_SCOPES), + "actions": ["list_events", "create_event", "delete_event"], + "available": calendar_list_events is not None, + }, + "documents": { + "read": scoped(DOCS_READ_SCOPES), + "write": scoped(DOCS_WRITE_SCOPES), + "actions": ["library", "read", "create", "delete"], + "available": documents_library_endpoint is not None, + }, }, "safety": { "email_send_requires_confirmation": True, @@ -166,4 +225,183 @@ def setup_codex_routes(email_router: APIRouter | None = None) -> APIRouter: owner=owner, ) + # ── Email draft + send ──────────────────────────────────────────────── + # Both handlers in routes/email_routes.py already accept `owner=` via + # FastAPI Depends, so we call them directly without patching state. + + @router.post("/emails/draft") + async def codex_email_draft(request: Request, body: dict[str, Any] = Body(default_factory=dict)): + owner = _scope_owner(request, EMAIL_DRAFT_SCOPES) + if email_draft_endpoint is None: + raise HTTPException(503, "Email integration is not available") + from routes.email_routes import SendEmailRequest + + try: + req = SendEmailRequest(**body) + except Exception as exc: + raise HTTPException(400, f"Invalid draft payload: {exc}") + return await email_draft_endpoint(req=req, owner=owner) + + @router.post("/emails/send") + async def codex_email_send(request: Request, body: dict[str, Any] = Body(default_factory=dict)): + owner = _scope_owner(request, EMAIL_SEND_SCOPES) + if email_send_endpoint is None: + raise HTTPException(503, "Email integration is not available") + from routes.email_routes import SendEmailRequest + + try: + req = SendEmailRequest(**body) + except Exception as exc: + raise HTTPException(400, f"Invalid send payload: {exc}") + return await email_send_endpoint(req=req, background_tasks=BackgroundTasks(), owner=owner) + + # ── Memory ──────────────────────────────────────────────────────────── + + @router.get("/memory") + async def codex_memory_list(request: Request): + owner = _scope_owner(request, MEMORY_READ_SCOPES) + if memory_list_endpoint is None: + raise HTTPException(503, "Memory integration is not available") + return await _as_owner(request, owner, memory_list_endpoint, request) + + @router.post("/memory") + async def codex_memory_add(request: Request, body: dict[str, Any] = Body(default_factory=dict)): + owner = _scope_owner(request, MEMORY_WRITE_SCOPES) + if memory_add_endpoint is None: + raise HTTPException(503, "Memory integration is not available") + from src.request_models import MemoryAddRequest + + try: + memory_data = MemoryAddRequest( + text=str(body.get("text") or "").strip(), + category=body.get("category", "fact"), + source=body.get("source", "user"), + session_id=body.get("session_id"), + ) + except Exception as exc: + raise HTTPException(400, f"Invalid memory payload: {exc}") + if not memory_data.text: + raise HTTPException(400, "Empty memory text") + return await _as_owner(request, owner, memory_add_endpoint, request, memory_data) + + # ── Calendar ────────────────────────────────────────────────────────── + + @router.get("/calendar/events") + async def codex_calendar_list(request: Request, start: str, end: str, calendar: str = ""): + owner = _scope_owner(request, CALENDAR_READ_SCOPES) + if calendar_list_events is None: + raise HTTPException(503, "Calendar integration is not available") + return await _as_owner(request, owner, calendar_list_events, request, start, end, calendar) + + @router.post("/calendar/events") + async def codex_calendar_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)): + owner = _scope_owner(request, CALENDAR_WRITE_SCOPES) + if calendar_create_event is None: + raise HTTPException(503, "Calendar integration is not available") + from routes.calendar_routes import EventCreate + + try: + data = EventCreate(**body) + except Exception as exc: + raise HTTPException(400, f"Invalid event payload: {exc}") + return await _as_owner(request, owner, calendar_create_event, request, data) + + # ── Documents ───────────────────────────────────────────────────────── + + @router.get("/documents") + async def codex_documents_library( + request: Request, + search: str | None = None, + language: str | None = None, + sort: str = "recent", + offset: int = 0, + limit: int = 50, + archived: bool = False, + ): + owner = _scope_owner(request, DOCS_READ_SCOPES) + if documents_library_endpoint is None: + raise HTTPException(503, "Documents integration is not available") + return await _as_owner( + request, owner, documents_library_endpoint, + request, search, language, sort, offset, limit, archived, + ) + + @router.get("/documents/{doc_id}") + async def codex_documents_get(request: Request, doc_id: str): + owner = _scope_owner(request, DOCS_READ_SCOPES) + if documents_get_endpoint is None: + raise HTTPException(503, "Documents integration is not available") + return await _as_owner(request, owner, documents_get_endpoint, request, doc_id) + + # ── DELETE endpoints so agents can clean up after themselves ────────── + + memory_delete_endpoint = _find_endpoint(memory_router, "DELETE", "/api/memory/{memory_id}") + calendar_delete_event = _find_endpoint(calendar_router, "DELETE", "/api/calendar/events/{uid}") + documents_delete_endpoint = _find_endpoint(document_router, "DELETE", "/api/document/{doc_id}") + + @router.delete("/memory/{memory_id}") + async def codex_memory_delete(request: Request, memory_id: str): + owner = _scope_owner(request, MEMORY_WRITE_SCOPES) + if memory_delete_endpoint is None: + raise HTTPException(503, "Memory delete not available") + return await _as_owner(request, owner, memory_delete_endpoint, request, memory_id) + + @router.delete("/calendar/events/{uid}") + async def codex_calendar_delete(request: Request, uid: str): + owner = _scope_owner(request, CALENDAR_WRITE_SCOPES) + if calendar_delete_event is None: + raise HTTPException(503, "Calendar delete not available") + return await _as_owner(request, owner, calendar_delete_event, request, uid) + + @router.delete("/documents/{doc_id}") + async def codex_documents_delete(request: Request, doc_id: str): + owner = _scope_owner(request, DOCS_WRITE_SCOPES) + if documents_delete_endpoint is None: + raise HTTPException(503, "Documents delete not available") + return await _as_owner(request, owner, documents_delete_endpoint, request, doc_id) + + @router.post("/documents") + async def codex_documents_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)): + owner = _scope_owner(request, DOCS_WRITE_SCOPES) + if documents_create_endpoint is None: + raise HTTPException(503, "Documents integration is not available") + from routes.document_routes import DocumentCreate + + try: + req = DocumentCreate(**body) + except Exception as exc: + raise HTTPException(400, f"Invalid document payload: {exc}") + return await _as_owner(request, owner, documents_create_endpoint, request, req) + + return router + + +def setup_claude_routes() -> APIRouter: + """Serve the Claude Code skill bundle. + + Claude Code uses the same scope-gated `/api/codex/*` endpoints at runtime; + this router only exists to deliver the skill zip via `/api/claude/plugin.zip` + so the user-facing setup commands stay in the Claude namespace. + """ + router = APIRouter(prefix="/api/claude", tags=["claude"]) + + @router.get("/plugin.zip") + def plugin_zip(request: Request): + require_user(request) + # Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump + # README.md or other bundle metadata into the user's claude config dir. + skills_root = Path(__file__).resolve().parent.parent / "integrations" / "claude" / "skills" + if not skills_root.exists(): + raise HTTPException(404, "Claude skill bundle not found") + bundle_root = skills_root.parent + buf = BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(skills_root.rglob("*")): + if path.is_dir() or "__pycache__" in path.parts or path.suffix == ".pyc": + continue + zf.write(path, path.relative_to(bundle_root)) + buf.seek(0) + headers = {"Content-Disposition": 'attachment; filename="odysseus-claude-skill.zip"'} + return StreamingResponse(buf, media_type="application/zip", headers=headers) + return router diff --git a/routes/task_routes.py b/routes/task_routes.py index baa903b..ebe9da1 100644 --- a/routes/task_routes.py +++ b/routes/task_routes.py @@ -446,7 +446,6 @@ def setup_task_routes(task_scheduler) -> APIRouter: "summarize_emails": ("email_summaries",), "draft_email_replies": ("email_ai_replies",), "extract_email_events": ("email_calendar_extractions",), - "mark_email_boundaries": ("email_boundaries",), "learn_sender_signatures": ("sender_signatures",), "check_email_urgency": ("email_tags", "email_urgency_alerts"), } diff --git a/src/builtin_actions.py b/src/builtin_actions.py index c107bec..0b19e35 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -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": , "quote_start": }\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 , 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", diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 1f8e0e6..4384705 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -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: diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 70a446c..f55fb82 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -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)"}, diff --git a/static/index.html b/static/index.html index 9cbf76e..baa471f 100644 --- a/static/index.html +++ b/static/index.html @@ -1886,7 +1886,7 @@

Email Accounts

Add, edit, delete, and test accounts in Integrations.
- +
@@ -1894,7 +1894,7 @@

Email Tasks

Manage email background tasks in Tasks.
- +
@@ -2115,12 +2115,12 @@ diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index c4d7343..971beff 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -2456,6 +2456,26 @@ async function _reconnectTask(el, task) { const isNetwork = /connection|timeout|timed out|incompleteread|chunkedencoding|reset by peer|protocolerror|all connection attempts failed/i.test(lastOutput); const progressMatch = String(lastOutput || '').match(/(\d+)%\|/); const nearDone = progressMatch && Number(progressMatch[1]) >= 80; + // Reconnect: most "crashed" downloads near the end are actually + // finished — we just missed the DOWNLOAD_OK / /snapshots/ marker + // because output rolled over, or the tmux session ended a tick + // before we polled. Probing has-session and re-attaching to + // capture-pane lets the existing _reconnectTask flow pick up + // the real state (running, finished, or truly dead). + const _reconnectFix = { + label: 'Reconnect', + action: () => { + _updateTask(task.sessionId, { status: 'running' }); + el.dataset.status = 'running'; + const badge2 = el.querySelector('.cookbook-task-status'); + if (badge2) { badge2.textContent = _statusLabel('running', task.type); badge2.className = 'cookbook-task-status'; } + const _diagEl = el.querySelector('.cookbook-diagnosis'); + if (_diagEl) _diagEl.remove(); + const _wave = el.querySelector('.cookbook-task-wave'); if (_wave) _wave.style.display = ''; + const _up = el.querySelector('.cookbook-task-uptime'); if (_up) _up.style.display = ''; + _reconnectTask(el, task); + }, + }; const diag = { message: isDisk ? 'Download stopped because this server ran out of disk space.' @@ -2467,28 +2487,88 @@ async function _reconnectTask(el, task) { suggestion: isDisk ? 'Suggested action: free disk space, then retry the download. HuggingFace resumes incomplete files when possible.' : nearDone - ? 'Suggested action: retry the download. It may briefly look like it restarted while cached files are checked, then it should reuse incomplete files.' - : 'Suggested action: retry the download. HuggingFace resumes incomplete files when possible.', - fixes: [ - { label: 'Retry download', action: () => _retryTask(el, task) }, - { label: 'Copy last 50 lines', action: () => { - const last = String(lastOutput || '').split('\n').slice(-50).join('\n'); - _copyText(last || 'No download log available.'); - } }, - ], + ? 'Suggested action: hit Reconnect first — the download may have finished after the output buffer rolled over. Retry only if reconnect cannot recover.' + : 'Suggested action: hit Reconnect to re-attach to the tmux session. If that fails, retry — HuggingFace resumes incomplete files when possible.', + fixes: isDisk + ? [ + { label: 'Retry download', action: () => _retryTask(el, task) }, + { label: 'Copy last 50 lines', action: () => { + const last = String(lastOutput || '').split('\n').slice(-50).join('\n'); + _copyText(last || 'No download log available.'); + } }, + ] + : [ + _reconnectFix, + { label: 'Retry download', action: () => _retryTask(el, task) }, + { label: 'Copy last 50 lines', action: () => { + const last = String(lastOutput || '').split('\n').slice(-50).join('\n'); + _copyText(last || 'No download log available.'); + } }, + ], }; _showDiagnosis(el, diag, lastOutput); + // Auto-probe: if the tmux session is still alive (download + // genuinely still in progress), _selfHealStaleTasks flips the + // task back to running and the diagnosis disappears without + // the user needing to click Reconnect. + if (nearDone) setTimeout(() => { _selfHealStaleTasks().catch(() => {}); }, 1200); } _showCookbookNotif(true); } else { - _updateTask(task.sessionId, { status: 'done' }); - el.dataset.status = 'done'; - const badge = el.querySelector('.cookbook-task-status'); - if (badge) { badge.textContent = _statusLabel('done', task.type); badge.className = 'cookbook-task-status cookbook-task-done'; } - const _chk = el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = ''; - const _sb = el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = ''; - _showCookbookNotif(); - _refreshDepsAfterInstall(task); + // Debounce the done flip. Tmux capture-pane can fail transiently + // (network blip, ssh reconnect), and the verify has-session right + // above can briefly report dead even when the session is in the + // middle of finalizing. Marking done immediately + the periodic + // _selfHealStaleTasks then flipping back to running causes the + // status badge to oscillate between Finished and Downloading. + // Wait 30s and re-probe: only finalize as done if tmux is STILL + // gone. If the session resurfaces, restart _reconnectTask so live + // capture resumes without the user seeing a fake "done" first. + if (!task._doneConfirmAt) { + _updateTask(task.sessionId, { _doneConfirmAt: Date.now() + 30000 }); + setTimeout(async () => { + try { + const fresh = _loadTasks().find(t => t.sessionId === task.sessionId); + if (!fresh) return; + let stillAlive = false; + try { + const probe = await fetch('/api/shell/exec', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`), timeout: 5 }), + }); + const pData = await probe.json(); + stillAlive = pData.exit_code === 0; + } catch { /* network blip — treat as inconclusive, prefer running */ stillAlive = true; } + if (stillAlive) { + _updateTask(task.sessionId, { status: 'running', _doneConfirmAt: null }); + const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`); + if (_el) { + _el.dataset.status = 'running'; + const _badge = _el.querySelector('.cookbook-task-status'); + if (_badge) { _badge.textContent = _statusLabel('running', task.type); _badge.className = 'cookbook-task-status'; } + const _wave = _el.querySelector('.cookbook-task-wave'); if (_wave) _wave.style.display = ''; + const _up = _el.querySelector('.cookbook-task-uptime'); if (_up) _up.style.display = ''; + _reconnectTask(_el, _loadTasks().find(t => t.sessionId === task.sessionId)); + } + return; + } + _updateTask(task.sessionId, { status: 'done', _doneConfirmAt: null }); + const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`); + if (_el) { + _el.dataset.status = 'done'; + const _badge = _el.querySelector('.cookbook-task-status'); + if (_badge) { _badge.textContent = _statusLabel('done', task.type); _badge.className = 'cookbook-task-status cookbook-task-done'; } + const _chk = _el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = ''; + const _sb = _el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = ''; + } + _showCookbookNotif(); + _refreshDepsAfterInstall(task); + _renderRunningTab(); + _processQueue(); + } catch { /* swallow — next polling cycle will retry */ } + }, 30000); + } } } _renderRunningTab(); diff --git a/static/js/modalManager.js b/static/js/modalManager.js index 9cb81f0..59e0b7b 100644 --- a/static/js/modalManager.js +++ b/static/js/modalManager.js @@ -938,6 +938,7 @@ function _wireChipDrag(chip, dock) { if (tz) { const dx = (tz.left + tz.width / 2) - (l.x + l.width / 2); const dy = (tz.top + tz.height / 2) - (l.y + l.height / 2); + l.chip.classList.add('chip-trashing'); l.chip.style.transition = 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in, left 0.32s cubic-bezier(0.45, 0, 0.25, 1), top 0.32s cubic-bezier(0.45, 0, 0.25, 1)'; // Whirlpool: spin + shrink so the chip swirls into the X. l.chip.style.transform = 'scale(0.15) rotate(720deg)'; @@ -1001,6 +1002,7 @@ function _wireChipDrag(chip, dock) { // `!important`, so the close animation needs setProperty(...important) // too or the styles don't apply and the chip just snaps. const cur = chip.style.transform || 'translate(0,0)'; + chip.classList.add('chip-trashing'); chip.style.setProperty('transition', 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in', 'important'); // Whirlpool: spin + shrink as the chip swirls into the X. chip.style.setProperty('transform', `${cur} scale(0.15) rotate(720deg)`, 'important'); diff --git a/static/js/notes.js b/static/js/notes.js index 44f98a4..6bd0ccc 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -2145,6 +2145,21 @@ function _bindCardEvents(body) { }); }); } + // Mobile, non-select: tapping anywhere on the card body (not on an + // interactive child — buttons, pin, checkbox, color dot, reminder pill, + // agent tag, links) opens the fullscreen editor. Previously only the + // title / content preview triggered edit, so padding + empty gutters were + // dead zones that felt broken on mobile. + if (_isNotesMobileMode() && !_selectMode) { + const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest(_INTERACTIVE)) return; + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } // Multi-select checkbox (only in select mode) body.querySelectorAll('.note-card-cb').forEach(cb => { cb.addEventListener('click', (e) => e.stopPropagation()); @@ -3456,6 +3471,14 @@ function _buildForm(note = null) { // let repeated clicks create duplicate notes. const _saveBtn = form.querySelector('.note-form-save'); if (_saveBtn._saving) return; + // Mobile: when an existing note is opened and closed without edits, the + // Update (✓) button morphs into Archive (set up below). Route the click + // to the hidden archive button so the existing archive flow + undo toast + // run unchanged. + if (_saveBtn.classList.contains('archive-mode')) { + form.querySelector('.note-form-archive-btn')?.click(); + return; + } _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5'; try { const title = form.querySelector('.note-form-title').value.trim(); @@ -3550,6 +3573,28 @@ function _buildForm(note = null) { } }); + // Mobile-only: when editing an existing note, the Update (✓) button starts in + // archive-mode (visually + behaviorally) and flips to Update on the first + // edit. Lets the user tap a note to skim, then tap ✓ to archive without ever + // touching a separate Archive button. + if (isEdit && window.innerWidth <= 768) { + const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label'); + const _enterArchive = () => { + _saveBtnEl0.classList.add('archive-mode'); + if (_saveLabelEl) _saveLabelEl.textContent = 'Archive'; + _saveBtnEl0.title = 'Archive'; + }; + const _enterUpdate = () => { + if (!_saveBtnEl0.classList.contains('archive-mode')) return; + _saveBtnEl0.classList.remove('archive-mode'); + if (_saveLabelEl) _saveLabelEl.textContent = 'Update'; + _saveBtnEl0.title = 'Update'; + }; + _enterArchive(); + form.addEventListener('input', _enterUpdate, true); + form.addEventListener('change', _enterUpdate, true); + } + // Cancel form.querySelector('.note-form-cancel').addEventListener('click', () => { _clearDraft(isEdit ? note.id : '__new__'); _editingId = null; _renderNotes(); }); diff --git a/static/js/settings.js b/static/js/settings.js index 49fea63..dd9240f 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -3093,10 +3093,69 @@ const INTG_TYPES = { carddav: { label: 'CardDAV', icon: '' }, email: { label: 'Email', icon: '' }, mcp: { label: 'MCP', icon: '' }, - codex: { label: 'Codex', icon: '' }, + codex: { label: 'Codex', icon: '' }, + claude: { label: 'Claude', icon: '' }, vault: { label: 'Vault', icon: '' }, }; +// Config shared by the Codex Agent and Claude Agent forms. Both use the same +// scope-gated /api/codex/* backend; this just parameterizes the UI label, +// default token name, and the per-agent install commands. +const AGENT_CONFIGS = { + codex: { + label: 'Codex Agent', + word: 'Codex', + namePrefix: 'codex agent', + defaultName: 'Codex Agent', + pluginPath: '/api/codex/plugin.zip', + setupDescription: 'Downloads the plugin bundle and registers it with Codex. Sets ODYSSEUS_URL + ODYSSEUS_API_TOKEN, fetches the plugin from this Odysseus instance, and runs codex plugin add odysseus@personal.', + buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin} +export ODYSSEUS_API_TOKEN='${token}' +mkdir -p ~/plugins +curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip +python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins +python3 - <<'PY' +import json +from pathlib import Path + +p = Path.home() / ".agents" / "plugins" / "marketplace.json" +p.parent.mkdir(parents=True, exist_ok=True) +if p.exists(): + data = json.loads(p.read_text()) +else: + data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []} + +data.setdefault("name", "personal") +data.setdefault("interface", {}).setdefault("displayName", "Personal") +plugins = data.setdefault("plugins", []) +entry = { + "name": "odysseus", + "source": {"source": "local", "path": "./plugins/odysseus"}, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Productivity", +} +data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry] +p.write_text(json.dumps(data, indent=2) + "\\n") +PY +codex plugin add odysseus@personal +python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`, + }, + claude: { + label: 'Claude Agent', + word: 'Claude', + namePrefix: 'claude agent', + defaultName: 'Claude Agent', + pluginPath: '/api/claude/plugin.zip', + setupDescription: 'Downloads the skill bundle into ~/.claude/skills/odysseus/. Sets ODYSSEUS_URL + ODYSSEUS_API_TOKEN, fetches the skill from this Odysseus instance. Claude Code auto-loads the skill on next start.', + buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin} +export ODYSSEUS_API_TOKEN='${token}' +mkdir -p ~/.claude +curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip +python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/ +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities`, + }, +}; + let _unifiedInited = false; async function initUnifiedIntegrations() { @@ -3169,10 +3228,17 @@ async function initUnifiedIntegrations() { } for (const tok of (Array.isArray(tokenRes) ? tokenRes : [])) { const scopes = tok.scopes || []; - const isCodex = (tok.name || '').toLowerCase().includes('codex') || scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:')); - if (!isCodex) continue; + const lowerName = (tok.name || '').toLowerCase(); + let agentType = null; + if (lowerName.startsWith('claude agent')) agentType = 'claude'; + else if (lowerName.startsWith('codex agent')) agentType = 'codex'; + else if (scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'))) { + // Legacy / un-prefixed scoped tokens fall back to Codex for backwards compat. + agentType = 'codex'; + } + if (!agentType) continue; const detail = `${tok.token_prefix || 'token'}... - ${scopes.join(', ') || 'chat'}`; - items.push({ type: 'codex', id: tok.id, name: tok.name || 'Codex Agent', detail, enabled: true, data: tok }); + items.push({ type: agentType, id: tok.id, name: tok.name || (agentType === 'claude' ? 'Claude Agent' : 'Codex Agent'), detail, enabled: true, data: tok }); } // Vaultwarden removed as an integration option. return items; @@ -3184,9 +3250,9 @@ async function initUnifiedIntegrations() { // type gets. (The clickable glow-on-test variant for email was // removed earlier; this matches the API/CalDAV/MCP pattern.) const statusDot = item.enabled - ? '' + ? '' : ''; - return `
+ return `
${t.icon}
${item.name} ${t.label}
@@ -3245,7 +3311,7 @@ async function initUnifiedIntegrations() { } else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' }); - else if (type === 'codex') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' }); + else if (type === 'codex' || type === 'claude') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' }); else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' }); } catch (_) {} formEl.style.display = 'none'; @@ -3262,7 +3328,8 @@ async function initUnifiedIntegrations() { else if (type === 'contacts' || type === 'carddav') showCardDavForm(); else if (type === 'email') showEmailForm(editId); else if (type === 'mcp') showMcpForm(editId); - else if (type === 'codex') showCodexForm(editId); + else if (type === 'codex') showAgentForm('codex', editId); + else if (type === 'claude') showAgentForm('claude', editId); else if (type === 'vault') showVaultForm(); } @@ -3287,15 +3354,46 @@ async function initUnifiedIntegrations() { // and they're patchy on mobile browsers. A native select renders // the same everywhere and makes the available options visible // without needing the user to type. - const selectOpts = presetEntries - .sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0])) + const sortedPresets = presetEntries.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0])); + const selectOpts = sortedPresets .map(([k, p]) => ``) .join(''); + // Letter-in-brand-color logo for each API preset; outline plug icon for + // "Custom (no preset)". Matches the email-provider dropdown pattern. + const _apiLetter = (letter, bg) => ``; + const _apiCustomIco = ''; + const API_PRESET_LOGO = { + miniflux: _apiLetter('M', '#214c87'), + gitea: _apiLetter('G', '#609926'), + linkding: _apiLetter('L', '#1f2937'), + home_assistant: _apiLetter('H', '#41bdf5'), + ntfy: _apiLetter('n', '#317f43'), + vaultwarden: _apiLetter('V', '#175ddc'), + freshrss: _apiLetter('R', '#ef6c00'), + }; + const _apiIconFor = (k) => { + if (!k) return _apiCustomIco; + if (API_PRESET_LOGO[k]) return API_PRESET_LOGO[k]; + const first = (presets[k]?.name || k).trim().charAt(0).toUpperCase() || '?'; + return _apiLetter(first, '#6b7280'); + }; + const _apiRows = [['', 'Custom (no preset)'], ...sortedPresets.map(([k, p]) => [k, p.name || k])] + .map(([k, label]) => ``).join(''); formEl.innerHTML = `
-

${editId ? 'Edit' : 'Add'} API Integration

+

API Integration

-
+
+
+ + + +
+
@@ -3305,6 +3403,48 @@ async function initUnifiedIntegrations() {
`; + // Custom preset dropdown wire-up (hidden select stays as data source). + (() => { + const trig = el('uf-api-preset-trigger'); + const menu = el('uf-api-preset-menu'); + const sel = el('uf-api-preset'); + if (!trig || !menu || !sel) return; + const lbl = trig.querySelector('.ufapi-label'); + const ico = trig.querySelector('.ufapi-icon'); + const _setFromKey = (k) => { + const row = menu.querySelector(`.ufapi-option[data-value="${k}"]`); + const text = row?.querySelector('span')?.textContent || 'Custom (no preset)'; + if (lbl) lbl.textContent = text; + if (ico) ico.innerHTML = _apiIconFor(k); + }; + const _close = () => { menu.style.display = 'none'; }; + const _open = () => { + menu.style.display = 'block'; + const tRect = trig.getBoundingClientRect(); + const mRect = menu.getBoundingClientRect(); + const below = window.innerHeight - tRect.bottom; + const above = tRect.top; + if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; } + else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; } + const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } }; + setTimeout(() => document.addEventListener('click', onDoc, true), 0); + }; + trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); }); + menu.querySelectorAll('.ufapi-option').forEach(btn => { + btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); + btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const k = btn.dataset.value || ''; + sel.value = k; + _setFromKey(k); + _close(); + sel.dispatchEvent(new Event('change', { bubbles: true })); + }); + }); + _setFromKey(sel.value || ''); + })(); + const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint'); let _editId = editId && editId !== 'new' ? editId : null; // Load existing @@ -3462,17 +3602,27 @@ async function initUnifiedIntegrations() { async function showCardDavForm() { formEl.innerHTML = `
-

Contacts (CardDAV)

+

Contacts (CardDAV)

-
+
+ + + +
-

Contacts Import

+

Contacts Import

@@ -3632,8 +3782,14 @@ async function initUnifiedIntegrations() {
${esc(c.name || '(no name)')}
${esc(sub)}
- - + +