feat: add api key auth, devii agent, openai gateway, and admin service management

This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
This commit is contained in:
2026-06-08 15:38:33 +00:00
parent 921e382cbc
commit e0535bb7c5
270 changed files with 54408 additions and 541 deletions
@@ -0,0 +1,7 @@
# retoor <retoor@molodetz.nl>
from .catalog import ACTIONS, PLATFORM_CATALOG
from .dispatcher import Dispatcher
from .spec import Action, Catalog, Param
__all__ = ["ACTIONS", "PLATFORM_CATALOG", "Dispatcher", "Action", "Catalog", "Param"]
@@ -0,0 +1,149 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
DEVII = (
"Controls devii, the on-screen avatar that is you rendered as a classic animated "
"desktop character. Only effective in the web interface; in other interfaces it reports "
"that no avatar is attached."
)
AVATAR_ACTIONS: tuple[Action, ...] = (
Action(
name="avatar_show",
method="LOCAL",
path="",
summary="Show devii (yourself) on screen",
description=DEVII,
handler="avatar",
requires_auth=False,
),
Action(
name="avatar_hide",
method="LOCAL",
path="",
summary="Hide devii from the screen (plays a goodbye/hide animation)",
description=(
DEVII + " This already plays a goodbye animation, so do not queue a separate "
"wave/goodbye animation immediately before calling it - hiding interrupts a still-"
"queued animation."
),
handler="avatar",
requires_auth=False,
),
Action(
name="avatar_speak",
method="LOCAL",
path="",
summary="Show a line of text in devii's speech balloon",
description=DEVII,
handler="avatar",
requires_auth=False,
params=(
arg("text", "The text devii should say.", required=True),
arg("hold", "Keep the balloon open until the next action.", kind="boolean"),
),
),
Action(
name="avatar_list_animations",
method="LOCAL",
path="",
summary="List every animation the current character supports",
description=DEVII,
handler="avatar",
requires_auth=False,
),
Action(
name="avatar_play_animation",
method="LOCAL",
path="",
summary="Play a specific named animation (use avatar_list_animations first)",
description=(
DEVII + " A following avatar_hide or avatar_stop interrupts an animation that is "
"still playing, so do not hide immediately after if you want it to finish."
),
handler="avatar",
requires_auth=False,
params=(arg("name", "Exact animation name.", required=True),),
),
Action(
name="avatar_random_animation",
method="LOCAL",
path="",
summary="Play a random non-idle animation to react expressively",
description=DEVII,
handler="avatar",
requires_auth=False,
),
Action(
name="avatar_move_to",
method="LOCAL",
path="",
summary="Walk devii to a pixel position on screen",
description=DEVII,
handler="avatar",
requires_auth=False,
params=(
arg("x", "Target x in pixels.", required=True, kind="integer"),
arg("y", "Target y in pixels.", required=True, kind="integer"),
arg("duration", "Movement duration in milliseconds.", kind="integer"),
),
),
Action(
name="avatar_gesture_at",
method="LOCAL",
path="",
summary="Make devii gesture toward a pixel position",
description=DEVII,
handler="avatar",
requires_auth=False,
params=(
arg("x", "Target x in pixels.", required=True, kind="integer"),
arg("y", "Target y in pixels.", required=True, kind="integer"),
),
),
Action(
name="avatar_get_viewport",
method="LOCAL",
path="",
summary="Get the screen size and devii's current position",
description=DEVII,
handler="avatar",
requires_auth=False,
),
Action(
name="avatar_stop",
method="LOCAL",
path="",
summary="Stop all of devii's queued animations and speech immediately",
description=DEVII,
handler="avatar",
requires_auth=False,
),
Action(
name="avatar_list_characters",
method="LOCAL",
path="",
summary="List every character devii can appear as",
description=DEVII,
handler="avatar",
requires_auth=False,
),
Action(
name="avatar_switch_character",
method="LOCAL",
path="",
summary="Change which character devii is rendered as",
description=DEVII,
handler="avatar",
requires_auth=False,
params=(arg("name", "Character name (e.g. Clippy, Merlin, Bonzi, Genie, Rover).", required=True),),
),
)
@@ -0,0 +1,692 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Catalog, Param
def path(name: str, description: str, required: bool = True) -> Param:
return Param(name=name, location="path", description=description, required=required)
def query(name: str, description: str, required: bool = False) -> Param:
return Param(name=name, location="query", description=description, required=required)
def body(name: str, description: str, required: bool = False) -> Param:
return Param(name=name, location="body", description=description, required=required)
def upload(name: str, description: str) -> Param:
return Param(name=name, location="file", description=description, required=True)
ATTACHMENTS = "Comma separated attachment uids returned by upload_file."
TARGET_TYPE = "Target type, e.g. post, comment, project, gist."
ACTIONS: tuple[Action, ...] = (
Action(
name="auth_status",
method="GET",
path="",
summary="Report whether the current session is authenticated",
handler="status",
requires_auth=False,
),
Action(
name="login",
method="POST",
path="/auth/login",
summary="Authenticate with email and password and start a session",
handler="login",
requires_auth=False,
params=(
body("email", "Account email address.", required=True),
body("password", "Account password.", required=True),
body("remember_me", "Keep the session alive longer ('on' or '')."),
),
),
Action(
name="logout",
method="GET",
path="/auth/logout",
summary="End the current session",
handler="logout",
requires_auth=True,
),
Action(
name="signup",
method="POST",
path="/auth/signup",
summary="Create a new account",
requires_auth=False,
params=(
body("username", "Desired username.", required=True),
body("email", "Email address.", required=True),
body("password", "Password (minimum six characters).", required=True),
body("confirm_password", "Password confirmation.", required=True),
),
),
Action(
name="forgot_password",
method="POST",
path="/auth/forgot-password",
summary="Request a password reset email",
requires_auth=False,
params=(body("email", "Account email address.", required=True),),
),
Action(
name="reset_password",
method="POST",
path="/auth/reset-password/{token}",
summary="Reset a password using a reset token",
requires_auth=False,
params=(
path("token", "Reset token from the email link."),
body("password", "New password.", required=True),
body("confirm_password", "New password confirmation.", required=True),
),
),
Action(
name="view_feed",
method="GET",
path="/feed",
summary="View the activity feed",
params=(
query("tab", "Feed tab to view."),
query("topic", "Filter by topic."),
query("before", "Pagination cursor."),
),
),
Action(
name="create_post",
method="POST",
path="/posts/create",
summary="Create a new post",
params=(
body("content", "Post body text.", required=True),
body("title", "Optional post title."),
body("topic", "Optional topic."),
body("project_uid", "Attach the post to a project uid."),
body("attachment_uids", ATTACHMENTS),
body("poll_question", "Optional poll question."),
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
),
),
Action(
name="view_post",
method="GET",
path="/posts/{post_slug}",
summary="View a single post by slug",
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
),
Action(
name="edit_post",
method="POST",
path="/posts/edit/{post_slug}",
summary="Edit an existing post",
params=(
path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),
body("content", "Updated post body.", required=True),
body("title", "Updated title."),
body("topic", "Updated topic."),
body("poll_question", "Optional poll question. Adds a poll to a post that does not already have one."),
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
),
),
Action(
name="delete_post",
method="POST",
path="/posts/delete/{post_slug}",
summary="Delete a post",
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
),
Action(
name="create_comment",
method="POST",
path="/comments/create",
summary="Create a comment on a post or other target",
params=(
body("content", "Comment body.", required=True),
body("post_uid", "Uid of the post being commented on."),
body("target_uid", "Uid of the target when not a post."),
body("target_type", TARGET_TYPE),
body("parent_uid", "Parent comment uid for replies."),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="delete_comment",
method="POST",
path="/comments/delete/{comment_uid}",
summary="Delete a comment",
params=(path("comment_uid", "Uid of the comment."),),
),
Action(
name="list_projects",
method="GET",
path="/projects",
summary="List projects",
params=(
query("tab", "Projects tab."),
query("search", "Search query."),
query("user_uid", "Filter by owner uid."),
query("project_type", "Filter by project type."),
query("before", "Pagination cursor."),
),
),
Action(
name="view_project",
method="GET",
path="/projects/{project_slug}",
summary="View a project by slug",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
),
Action(
name="create_project",
method="POST",
path="/projects/create",
summary="Create a new project",
params=(
body("title", "Project title.", required=True),
body("description", "Project description.", required=True),
body("release_date", "Release date."),
body("demo_date", "Demo date."),
body("project_type", "Project type."),
body("platforms", "Supported platforms."),
body("status", "Project status."),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="delete_project",
method="POST",
path="/projects/delete/{project_slug}",
summary="Delete a project",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
),
Action(
name="search_users",
method="GET",
path="/profile/search",
summary="Search for users by name",
params=(query("q", "Search query."),),
),
Action(
name="view_profile",
method="GET",
path="/profile/{username}",
summary="View a user profile",
params=(
path("username", "Username to view."),
query("tab", "Profile tab."),
),
),
Action(
name="update_profile",
method="POST",
path="/profile/update",
summary="Update the current user's profile",
params=(
body("bio", "Profile biography."),
body("location", "Location."),
body("git_link", "Git profile link."),
body("website", "Personal website."),
),
),
Action(
name="regenerate_api_key",
method="POST",
path="/profile/regenerate-api-key",
summary="Issue a new API key and invalidate the current one",
description=(
"Returns the new api_key. Warning: this immediately invalidates any key currently "
"used for authentication, so confirm with the user before calling it."
),
),
Action(
name="list_messages",
method="GET",
path="/messages",
summary="View direct message conversations",
params=(
query("with_uid", "Open a conversation with a user uid."),
query("search", "Search conversations."),
),
),
Action(
name="search_message_users",
method="GET",
path="/messages/search",
summary="Search users to message",
params=(query("q", "Search query."),),
),
Action(
name="send_message",
method="POST",
path="/messages/send",
summary="Send a direct message",
params=(
body("content", "Message body.", required=True),
body("receiver_uid", "Recipient user uid.", required=True),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="list_notifications",
method="GET",
path="/notifications",
summary="List notifications",
params=(query("before", "Pagination cursor."),),
),
Action(
name="notification_counts",
method="GET",
path="/notifications/counts",
summary="Get unread notification and message counts",
),
Action(
name="open_notification",
method="GET",
path="/notifications/open/{notification_uid}",
summary="Open a notification and follow it",
params=(path("notification_uid", "Notification uid."),),
),
Action(
name="mark_notification_read",
method="POST",
path="/notifications/mark-read/{notification_uid}",
summary="Mark a single notification read",
params=(path("notification_uid", "Notification uid."),),
),
Action(
name="mark_all_notifications_read",
method="POST",
path="/notifications/mark-all-read",
summary="Mark all notifications read",
),
Action(
name="vote",
method="POST",
path="/votes/{target_type}/{target_uid}",
summary="Cast or toggle a vote on a target",
description="Re-sending the same value removes the vote. Returns the net/up/down tally.",
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
body("value", "Vote value: 1 to upvote, -1 to downvote (re-send to remove).", required=True),
),
),
Action(
name="react",
method="POST",
path="/reactions/{target_type}/{target_uid}",
summary="Toggle an emoji reaction on a target",
description="Re-sending the same emoji removes it. Returns reaction counts.",
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
body("emoji", "One of the allowed reaction emoji.", required=True),
),
),
Action(
name="list_bookmarks",
method="GET",
path="/bookmarks/saved",
summary="List saved bookmarks",
params=(query("before", "Pagination cursor."),),
),
Action(
name="toggle_bookmark",
method="POST",
path="/bookmarks/{target_type}/{target_uid}",
summary="Toggle a bookmark on a target",
description="Re-sending removes the bookmark. Returns {saved: true|false}.",
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
),
),
Action(
name="vote_poll",
method="POST",
path="/polls/{poll_uid}/vote",
summary="Vote in a poll",
description="Casts or changes your vote on a poll option. Returns the option tallies.",
ajax=True,
params=(
path("poll_uid", "Poll uid."),
body("option_uid", "Chosen option uid.", required=True),
),
),
Action(
name="follow_user",
method="POST",
path="/follow/{username}",
summary="Follow a user",
params=(path("username", "Username to follow."),),
),
Action(
name="unfollow_user",
method="POST",
path="/follow/unfollow/{username}",
summary="Unfollow a user",
params=(path("username", "Username to unfollow."),),
),
Action(
name="list_followers",
method="GET",
path="/profile/{username}/followers",
summary="List the users who follow a profile",
requires_auth=False,
params=(
path("username", "Username whose followers to list."),
query("page", "Page number, 25 per page."),
),
),
Action(
name="list_following",
method="GET",
path="/profile/{username}/following",
summary="List the users a profile follows",
requires_auth=False,
params=(
path("username", "Username whose following to list."),
query("page", "Page number, 25 per page."),
),
),
Action(
name="view_leaderboard",
method="GET",
path="/leaderboard",
summary="View the leaderboard",
),
Action(
name="list_bugs",
method="GET",
path="/bugs",
summary="List reported bugs",
),
Action(
name="create_bug",
method="POST",
path="/bugs/create",
summary="Report a bug",
params=(
body("title", "Bug title.", required=True),
body("description", "Bug description.", required=True),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="list_gists",
method="GET",
path="/gists",
summary="List gists",
params=(
query("language", "Filter by language."),
query("user_uid", "Filter by owner uid."),
query("before", "Pagination cursor."),
),
),
Action(
name="view_gist",
method="GET",
path="/gists/{gist_slug}",
summary="View a gist by slug",
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
),
Action(
name="create_gist",
method="POST",
path="/gists/create",
summary="Create a gist",
params=(
body("title", "Gist title.", required=True),
body("source_code", "Gist source code.", required=True),
body("description", "Gist description."),
body("language", "Programming language."),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="edit_gist",
method="POST",
path="/gists/edit/{gist_slug}",
summary="Edit a gist",
params=(
path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),
body("title", "Gist title.", required=True),
body("source_code", "Gist source code.", required=True),
body("description", "Gist description."),
body("language", "Programming language."),
),
),
Action(
name="delete_gist",
method="POST",
path="/gists/delete/{gist_slug}",
summary="Delete a gist",
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
),
Action(
name="list_news",
method="GET",
path="/news",
summary="List news articles",
params=(query("before", "Pagination cursor."),),
),
Action(
name="view_news",
method="GET",
path="/news/{news_slug}",
summary="View a news article by slug",
params=(path("news_slug", "Exact news slug copied from a /news/... link in a listing response; do not build it from the title."),),
),
Action(
name="upload_file",
method="POST",
path="/uploads/upload",
summary="Upload a local file and obtain its attachment uid",
params=(upload("file", "Local filesystem path of the file to upload."),),
),
Action(
name="delete_attachment",
method="DELETE",
path="/uploads/delete/{attachment_uid}",
summary="Delete an uploaded attachment",
params=(path("attachment_uid", "Uid of the attachment."),),
),
Action(
name="admin_overview",
method="GET",
path="/admin",
summary="View the admin overview",
),
Action(
name="site_analytics",
method="GET",
path="/admin/analytics",
summary="Site-wide aggregate analytics in one call (admin only)",
description=(
"Returns JSON: total members, active users in the last 24h/7d/30d, users signed in "
"now, new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
"news), and top authors. Use this for any 'how many'/'how active'/count question "
"instead of paging through admin_list_users."
),
params=(query("top_n", "How many top authors to include (1-50)."),),
),
Action(
name="ai_usage",
method="GET",
path="/admin/ai-usage/data",
summary="AI gateway usage, cost, latency, and reliability metrics (admin only)",
description=(
"Returns JSON for a bounded window: request volume and throughput, token usage with "
"averages and percentiles, latency (upstream, gateway overhead, queue wait, connect), "
"error rates by category, cost in USD (per model, per caller, input vs output, projected "
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Use this for "
"any question about AI spend, token consumption, gateway performance, or errors."
),
params=(
query("hours", "Lookback window in hours (1-168, default 48)."),
query("top_n", "How many rows in each top-N breakdown (default 10)."),
),
),
Action(
name="admin_list_users",
method="GET",
path="/admin/users",
summary="List users for administration",
params=(query("page", "Page number."),),
),
Action(
name="admin_set_user_role",
method="POST",
path="/admin/users/{uid}/role",
summary="Set a user's role",
params=(
path("uid", "User uid."),
body("role", "New role.", required=True),
),
),
Action(
name="admin_set_user_password",
method="POST",
path="/admin/users/{uid}/password",
summary="Set a user's password",
params=(
path("uid", "User uid."),
body("password", "New password.", required=True),
),
),
Action(
name="admin_toggle_user",
method="POST",
path="/admin/users/{uid}/toggle",
summary="Toggle a user's active state",
params=(path("uid", "User uid."),),
),
Action(
name="admin_get_settings",
method="GET",
path="/admin/settings",
summary="View site settings",
),
Action(
name="admin_save_settings",
method="POST",
path="/admin/settings",
summary="Save site settings",
params=(
body("site_name", "Site name."),
body("site_description", "Site description."),
body("site_tagline", "Site tagline."),
body("max_upload_size_mb", "Maximum upload size in megabytes."),
body("allowed_file_types", "Allowed file types."),
body("max_attachments_per_resource", "Maximum attachments per resource."),
body("rate_limit_per_minute", "Rate limit per minute."),
body("rate_limit_window_seconds", "Rate limit window in seconds."),
body("session_max_age_days", "Session maximum age in days."),
body("session_remember_days", "Remember-me duration in days."),
body("registration_open", "Whether registration is open."),
body("maintenance_mode", "Whether maintenance mode is on."),
body("maintenance_message", "Maintenance message."),
),
),
Action(
name="admin_list_news",
method="GET",
path="/admin/news",
summary="List news for administration",
params=(query("page", "Page number."),),
),
Action(
name="admin_toggle_news",
method="POST",
path="/admin/news/{uid}/toggle",
summary="Toggle a news article",
params=(path("uid", "News uid."),),
),
Action(
name="admin_publish_news",
method="POST",
path="/admin/news/{uid}/publish",
summary="Publish a news article",
params=(path("uid", "News uid."),),
),
Action(
name="admin_landing_news",
method="POST",
path="/admin/news/{uid}/landing",
summary="Set a news article as landing content",
params=(path("uid", "News uid."),),
),
Action(
name="admin_delete_news",
method="POST",
path="/admin/news/{uid}/delete",
summary="Delete a news article",
params=(path("uid", "News uid."),),
),
Action(
name="admin_list_services",
method="GET",
path="/admin/services",
summary="View managed services",
),
Action(
name="admin_services_data",
method="GET",
path="/admin/services/data",
summary="Get live status, metrics, and log tail for every background service",
),
Action(
name="admin_service_status",
method="GET",
path="/admin/services/{name}/data",
summary="Get live status, metrics, and log tail for one background service",
params=(path("name", "Service name (e.g. news, bots, openai)."),),
),
Action(
name="admin_start_service",
method="POST",
path="/admin/services/{name}/start",
summary="Start a managed service",
params=(path("name", "Service name."),),
),
Action(
name="admin_stop_service",
method="POST",
path="/admin/services/{name}/stop",
summary="Stop a managed service",
params=(path("name", "Service name."),),
),
Action(
name="admin_run_service",
method="POST",
path="/admin/services/{name}/run",
summary="Run a managed service once",
params=(path("name", "Service name."),),
),
Action(
name="admin_clear_service_logs",
method="POST",
path="/admin/services/{name}/clear-logs",
summary="Clear a managed service's logs",
params=(path("name", "Service name."),),
),
Action(
name="admin_config_service",
method="POST",
path="/admin/services/{name}/config",
summary="Update a managed service's configuration",
params=(path("name", "Service name."),),
freeform_body=True,
),
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
@@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
CHUNK_ACTIONS: tuple[Action, ...] = (
Action(
name="read_more",
method="LOCAL",
path="",
summary="Read the next part of a previously truncated tool result",
description=(
"When any tool result is truncated it includes a chunk_id, total_chars, "
"remaining_chars, and next_offset. Call read_more with that chunk_id and offset to "
"page through the rest until remaining_chars is 0, so you can read the whole content."
),
handler="chunks",
requires_auth=False,
params=(
Param(
name="chunk_id",
location="body",
description="The chunk_id from a truncated result.",
required=True,
),
Param(
name="offset",
location="body",
description="Character offset to read from (use next_offset from the prior part).",
type="integer",
),
Param(
name="length",
location="body",
description="Optional number of characters to return (capped to the response limit).",
type="integer",
),
),
),
)
@@ -0,0 +1,112 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
CLIENT = (
"Runs in the user's own browser through the web terminal. Only effective in the web "
"interface with a live connection; elsewhere it reports that no browser is attached."
)
CLIENT_ACTIONS: tuple[Action, ...] = (
Action(
name="get_page_context",
method="LOCAL",
path="",
summary="Read the user's current page: URL, title, viewport, scroll, selected text, visible headings, and whether they are signed in",
description=CLIENT + " Use this to understand where the user is and what they are looking at before acting or guiding them.",
handler="client",
requires_auth=False,
),
Action(
name="run_js",
method="LOCAL",
path="",
summary="Execute JavaScript in the user's browser and return its result",
description=(
CLIENT + " The code is the body of an async function; use 'return value' to return a "
"JSON-serializable result. You have full access to window and document. Use this for "
"anything not covered by the dedicated tools: read or change the DOM, drive a live "
"demo, inspect state, or update the screen. Prefer the dedicated tools "
"(highlight_element, show_toast, scroll_to_element, navigate_to, reload_page) when they fit."
),
handler="client",
requires_auth=False,
params=(
arg("code", "JavaScript to run as an async function body. Return a JSON-serializable value.", required=True),
),
),
Action(
name="highlight_element",
method="LOCAL",
path="",
summary="Highlight an element on screen with an outline and an optional callout label, for live tutorials",
description=CLIENT + " Scrolls the element into view and draws an attention outline. Call clear_highlights to remove it.",
handler="client",
requires_auth=False,
params=(
arg("selector", "CSS selector, or the element's exact visible text (e.g. a heading or link label).", required=True),
arg("label", "Optional callout text shown next to the element."),
),
),
Action(
name="clear_highlights",
method="LOCAL",
path="",
summary="Remove all highlights and callouts placed by highlight_element",
description=CLIENT,
handler="client",
requires_auth=False,
),
Action(
name="show_toast",
method="LOCAL",
path="",
summary="Show a brief on-screen message (toast) to the user",
description=CLIENT,
handler="client",
requires_auth=False,
params=(
arg("text", "Message to display.", required=True),
arg("duration_ms", "How long to show it, in milliseconds (default 4000).", kind="integer"),
),
),
Action(
name="scroll_to_element",
method="LOCAL",
path="",
summary="Smoothly scroll an element into view",
description=CLIENT,
handler="client",
requires_auth=False,
params=(arg("selector", "CSS selector, or the element's exact visible text.", required=True),),
),
Action(
name="navigate_to",
method="LOCAL",
path="",
summary="Send the user's browser to a URL",
description=(
CLIENT + " Use a same-origin path like /feed or /docs/index.html, or a full URL. The "
"page reloads; the user's Devii session and conversation persist and reconnect automatically."
),
handler="client",
requires_auth=False,
params=(arg("url", "Path or URL to navigate to.", required=True),),
),
Action(
name="reload_page",
method="LOCAL",
path="",
summary="Refresh the user's current page, e.g. after something changed",
description=CLIENT + " The Devii session and conversation persist and reconnect automatically.",
handler="client",
requires_auth=False,
),
)
@@ -0,0 +1,22 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action
COST_ACTIONS: tuple[Action, ...] = (
Action(
name="cost_stats",
method="LOCAL",
path="",
summary="Report token usage and cost statistics for the current session",
description=(
"Returns this session's LLM token counts (prompt, completion, total, cache hit/miss, "
"reasoning), the cost in USD broken down by cache-hit input, cache-miss input, and "
"output, per-request averages, cache hit rate, and session timing. Costs are priced "
"as DeepSeek V4 Flash, the cheapest official DeepSeek model."
),
handler="cost",
requires_auth=False,
),
)
@@ -0,0 +1,223 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from typing import Any
from urllib.parse import quote, urlencode
from ..config import Settings
from ..errors import (
AuthRequiredError,
DeviiError,
ToolInputError,
error_result,
unexpected_result,
)
from ..agentic.controller import AgenticController
from ..agentic.state import record_mutation
from ..avatar import AvatarController
from ..chunks import ChunkController, get_store, serve_resource, wrap_if_large
from ..cost import CostController
from ..docs import DocsController
from ..fetch import FetchController
from ..http_client import PlatformClient
from ..rsearch import RsearchController
from ..tasks.controller import TaskController
from ..text import format_response
from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
logger = logging.getLogger("devii.dispatch")
class Dispatcher:
def __init__(
self,
catalog: Catalog,
client: PlatformClient,
settings: Settings,
tasks: TaskController,
agentic: AgenticController,
avatar: AvatarController | None = None,
browser: Any = None,
) -> None:
self._actions = catalog.by_name()
self._client = client
self._settings = settings
self._tasks = tasks
self._agentic = agentic
self._avatar = avatar
self._browser = browser
self._fetch = FetchController(settings)
self._docs = DocsController(settings)
self._cost = CostController()
self._chunks = ChunkController(settings)
self._rsearch = RsearchController(settings)
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
action = self._actions.get(name)
if action is None:
return error_result(ToolInputError(f"Unknown tool: {name}"))
logger.info("Dispatch %s args=%s", name, list(arguments))
try:
if action.requires_auth and not self._client.authenticated:
raise AuthRequiredError(
"Not authenticated. Ask the user for credentials and call the login tool first.",
tool=name,
)
resource_key = self._resource_key(action, arguments)
if resource_key:
cached = serve_resource(resource_key, self._settings.max_response_chars)
if cached is not None:
logger.info("Resource cache hit for %s (%s)", name, resource_key)
return cached
result = await self._run(action, arguments)
if action.handler == "chunks":
return result
return wrap_if_large(result, self._settings.max_response_chars, resource_key)
except DeviiError as exc:
logger.info("Dispatch %s failed: %s", name, exc.message)
return error_result(exc)
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
logger.exception("Dispatch %s crashed", name)
return unexpected_result(exc)
async def _run(self, action: Action, arguments: dict[str, Any]) -> str:
if action.handler == "status":
return json.dumps(
{
"authenticated": self._client.authenticated,
"user": self._client.username,
},
ensure_ascii=False,
)
if action.handler == "login":
result = await self._client.login(
email=self._require(arguments, "email"),
password=self._require(arguments, "password"),
remember_me=str(arguments.get("remember_me", "on")).lower() not in ("", "false", "off", "no"),
)
return json.dumps(result, ensure_ascii=False)
if action.handler == "logout":
return json.dumps(await self._client.logout(), ensure_ascii=False)
if action.handler == "task":
return await self._tasks.dispatch(action.name, arguments)
if action.handler == "agentic":
return await self._agentic.dispatch(action.name, arguments)
if action.handler == "fetch":
return await self._fetch.dispatch(action.name, arguments)
if action.handler == "docs":
return await self._docs.dispatch(action.name, arguments)
if action.handler == "cost":
return await self._cost.dispatch(action.name, arguments)
if action.handler == "chunks":
return await self._chunks.dispatch(action.name, arguments)
if action.handler == "rsearch":
return await self._rsearch.dispatch(action.name, arguments)
if action.handler == "avatar":
if self._avatar is None:
return error_result(
ToolInputError(
"No avatar is attached; devii's on-screen actions need the web interface."
)
)
return await self._avatar.dispatch(action.name, arguments)
if action.handler == "client":
if self._browser is None:
return error_result(
ToolInputError(
"No browser is attached; client-side actions need the web interface."
)
)
return await self._browser.dispatch(action.name, arguments)
return await self._run_http(action, arguments)
def _build_request(
self, action: Action, arguments: dict[str, Any]
) -> tuple[str, dict[str, Any], dict[str, Any], tuple[str, str] | None]:
url_path = action.path
params: dict[str, Any] = {}
data: dict[str, Any] = {}
file_field: tuple[str, str] | None = None
for param in action.params:
if param.name not in arguments or arguments[param.name] is None:
if param.required:
raise ToolInputError(
f"Missing required parameter '{param.name}' for {action.name}.")
continue
value = arguments[param.name]
if param.location == "path":
url_path = url_path.replace("{" + param.name + "}", quote(str(value), safe=""))
elif param.location == "query":
params[param.name] = value
elif param.location == "body":
data[param.name] = value
elif param.location == "file":
file_field = (param.name, str(value))
if action.freeform_body:
extra = arguments.get("form_fields") or {}
if isinstance(extra, dict):
data.update({str(k): v for k, v in extra.items()})
return url_path, params, data, file_field
def _resource_key(self, action: Action, arguments: dict[str, Any]) -> str | None:
try:
if action.handler == "fetch" and action.name == "fetch_url":
url = str(arguments.get("url", "")).strip()
if not url:
return None
if "://" not in url:
url = "https://" + url
return f"fetch:{url}"
if action.handler == "http" and action.method == "GET":
url_path, params, _, _ = self._build_request(action, arguments)
query = urlencode(sorted((str(k), str(v)) for k, v in params.items()))
return f"http:GET {url_path}?{query}"
except ToolInputError:
return None
return None
async def _run_http(self, action: Action, arguments: dict[str, Any]) -> str:
url_path, params, data, file_field = self._build_request(action, arguments)
headers = {"X-Requested-With": "fetch"} if action.ajax else None
response = await self._client.call(
method=action.method,
path=url_path,
params=params or None,
data=data or None,
file_field=file_field,
headers=headers,
)
if action.method in MUTATING_METHODS:
record_mutation(action.name)
store = get_store()
if store is not None:
store.invalidate_resources(prefix="http:")
return format_response(response)
@staticmethod
def _require(arguments: dict[str, Any], key: str) -> str:
value = arguments.get(key)
if value is None or str(value).strip() == "":
raise ToolInputError(f"Missing required field '{key}'.")
return str(value)
@@ -0,0 +1,35 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
DOCS_ACTIONS: tuple[Action, ...] = (
Action(
name="search_docs",
method="LOCAL",
path="",
summary="Full-text search the DevPlace API documentation",
description=(
"Searches the platform's developer documentation and returns the most relevant "
"sections (title and content). Use it to confirm how an endpoint, parameter, or "
"feature works before acting."
),
handler="docs",
requires_auth=False,
params=(
Param(
name="query",
location="body",
description="What to look for in the documentation.",
required=True,
),
Param(
name="max_results",
location="body",
description="Maximum number of documentation sections to return (1-10).",
type="integer",
),
),
),
)
@@ -0,0 +1,36 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
FETCH_ACTIONS: tuple[Action, ...] = (
Action(
name="fetch_url",
method="LOCAL",
path="",
summary="Fetch a web page and return its readable text content",
description=(
"Retrieves any http(s) URL as a real browser would (stealth headers) and returns "
"the page title and readable text with links preserved, safely truncated to fit "
"the context window. Use it to read external pages, then summarize or create posts, "
"gists, or articles from the content. Private and loopback addresses are refused."
),
handler="fetch",
requires_auth=False,
params=(
Param(
name="url",
location="body",
description="The page URL to fetch (https is assumed if no scheme is given).",
required=True,
),
Param(
name="max_chars",
location="body",
description="Optional cap on returned characters; clamped to a context-safe maximum.",
type="integer",
),
),
),
)
@@ -0,0 +1,85 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
REMOTE_NOTE = (
"This is an EXTERNAL public service, not this DevPlace platform. Only call it when the user "
"explicitly asks to search the web or an outside source; prefer platform tools otherwise."
)
RSEARCH_ACTIONS: tuple[Action, ...] = (
Action(
name="rsearch",
method="LOCAL",
path="",
summary="Search the public web (and images) via the external rsearch aggregator",
description=(
"Queries multiple independent web search providers and returns ranked results "
"(title, URL, description, source). Set content=true to also fetch each page's "
"readable text, deep=true for a deeper research pass, and type=images for image "
"results. " + REMOTE_NOTE
),
handler="rsearch",
requires_auth=False,
params=(
Param(name="query", location="body", description="The web search query.", required=True),
Param(name="count", location="body", description="Number of results (1-100, default 10).", type="integer"),
Param(name="content", location="body", description="Fetch full page content for each result.", type="boolean"),
Param(name="deep", location="body", description="Run a deeper research pass.", type="boolean"),
Param(name="type", location="body", description="Result type: 'web' (default) or 'images'."),
),
),
Action(
name="rsearch_answer",
method="LOCAL",
path="",
summary="Get an AI answer to a question, grounded in fresh public web search",
description=(
"Sends the question to an external AI that autonomously searches the public web and "
"returns a written answer plus the source links it used. Use for current, real-world "
"questions the platform cannot answer. " + REMOTE_NOTE
),
handler="rsearch",
requires_auth=False,
params=(
Param(name="query", location="body", description="The question or prompt to answer.", required=True),
Param(name="content", location="body", description="Let the AI read full page content while answering.", type="boolean"),
Param(name="count", location="body", description="Max sources to consider (1-100, default 10).", type="integer"),
),
),
Action(
name="rsearch_chat",
method="LOCAL",
path="",
summary="Send a prompt to the external rsearch AI for a direct answer (no web search)",
description=(
"Direct chat completion from the external rsearch AI model, with no web search or "
"platform context. Set json=true to force a JSON-only answer, or pass a system message "
"to steer the persona. " + REMOTE_NOTE
),
handler="rsearch",
requires_auth=False,
params=(
Param(name="prompt", location="body", description="The prompt to send.", required=True),
Param(name="json", location="body", description="Force a valid-JSON-only response.", type="boolean"),
Param(name="system", location="body", description="Optional system message to steer the answer."),
),
),
Action(
name="rsearch_describe_image",
method="LOCAL",
path="",
summary="Describe a public image URL using the external rsearch vision model",
description=(
"Sends an image URL to the external rsearch vision model and returns a written "
"description. " + REMOTE_NOTE
),
handler="rsearch",
requires_auth=False,
params=(
Param(name="url", location="body", description="Public URL of the image to describe.", required=True),
),
),
)
+90
View File
@@ -0,0 +1,90 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
ParamLocation = Literal["path", "query", "body", "file"]
@dataclass(frozen=True)
class Param:
name: str
location: ParamLocation
description: str
required: bool = False
type: str = "string"
@dataclass(frozen=True)
class Action:
name: str
method: str
path: str
summary: str
description: str = ""
params: tuple[Param, ...] = ()
requires_auth: bool = True
handler: Literal[
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
"docs", "cost", "chunks", "rsearch"
] = "http"
freeform_body: bool = False
ajax: bool = False
def tool_schema(self) -> dict[str, Any]:
properties: dict[str, Any] = {}
required: list[str] = []
for param in self.params:
if param.type == "array":
properties[param.name] = {
"type": "array",
"items": {"type": "object"},
"description": param.description,
}
else:
properties[param.name] = {
"type": param.type,
"description": param.description,
}
if param.required:
required.append(param.name)
if self.freeform_body:
properties["form_fields"] = {
"type": "object",
"description": "Additional form fields as key/value string pairs.",
"additionalProperties": {"type": "string"},
}
text = self.summary if not self.description else f"{self.summary}. {self.description}"
return {
"type": "function",
"function": {
"name": self.name,
"description": text,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
},
},
}
@dataclass(frozen=True)
class Catalog:
actions: tuple[Action, ...] = field(default_factory=tuple)
def by_name(self) -> dict[str, Action]:
return {action.name: action for action in self.actions}
def tool_schemas(self) -> list[dict[str, Any]]:
return [action.tool_schema() for action in self.actions]
def tool_schemas_for(self, authenticated: bool) -> list[dict[str, Any]]:
return [
action.tool_schema()
for action in self.actions
if authenticated or not action.requires_auth
]