docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API

- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
This commit is contained in:
2026-06-18 22:09:34 +00:00
parent 95dca73291
commit 6ceca3d0d4
146 changed files with 6079 additions and 392 deletions
+12 -6
View File
@@ -1531,7 +1531,7 @@ ACTIONS: tuple[Action, ...] = (
name="db_list_tables",
method="GET",
path="/dbapi/tables",
summary="List database tables exposed by the database API (admin only)",
summary="List database tables exposed by the database API (primary administrator only)",
description=(
"Returns every table reachable through the database API with its row count and "
"whether it uses soft deletes. Use this to discover what data exists before "
@@ -1539,21 +1539,23 @@ ACTIONS: tuple[Action, ...] = (
),
params=(),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_table_schema",
method="GET",
path="/dbapi/{table}/schema",
summary="Show a table's columns and types (admin only)",
summary="Show a table's columns and types (primary administrator only)",
description="Returns the column names, types, row count, and soft-delete flag for one table.",
params=(path("table", "Table name (from db_list_tables)."),),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_list_rows",
method="GET",
path="/dbapi/{table}",
summary="List rows of a table with keyset pagination (admin only)",
summary="List rows of a table with keyset pagination (primary administrator only)",
description=(
"Browses rows newest-first. Soft-deleted rows are excluded unless include_deleted is "
"true. For filtered or joined questions prefer db_query or db_design_query."
@@ -1572,12 +1574,13 @@ ACTIONS: tuple[Action, ...] = (
),
),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_get_row",
method="GET",
path="/dbapi/{table}/{key}/{value}",
summary="Fetch one row by a key column (admin only)",
summary="Fetch one row by a key column (primary administrator only)",
description="Returns a single row where key column equals value (key is usually 'uid').",
params=(
path("table", "Table name."),
@@ -1585,12 +1588,13 @@ ACTIONS: tuple[Action, ...] = (
path("value", "Value of the key column."),
),
requires_admin=True,
requires_primary_admin=True,
),
Action(
name="db_query",
method="POST",
path="/dbapi/query",
summary="Run a read-only SQL SELECT and return rows (admin only)",
summary="Run a read-only SQL SELECT and return rows (primary administrator only)",
description=(
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected. The database API is "
@@ -1603,13 +1607,14 @@ ACTIONS: tuple[Action, ...] = (
body("dialect", "Optional source SQL dialect (default sqlite)."),
),
requires_admin=True,
requires_primary_admin=True,
read_only=True,
),
Action(
name="db_design_query",
method="POST",
path="/dbapi/nl",
summary="Design a SQL SELECT from a natural-language question (admin only)",
summary="Design a SQL SELECT from a natural-language question (primary administrator only)",
description=(
"Turns a plain-language question about one table into a validated read-only SELECT. "
"It auto-adds 'deleted_at IS NULL' for soft-delete tables unless apply_soft_delete is "
@@ -1636,6 +1641,7 @@ ACTIONS: tuple[Action, ...] = (
),
),
requires_admin=True,
requires_primary_admin=True,
read_only=True,
),
Action(
@@ -55,6 +55,8 @@ CONFIRM_REQUIRED = {
"notification_reset",
"gateway_provider_delete",
"gateway_model_delete",
"email_account_delete",
"email_delete_message",
}
CONDITIONAL_CONFIRM = {
@@ -103,6 +105,14 @@ _DEVII_MECHANIC_EVENTS = {
"customize_reset": "devii.customization.reset",
"notification_set": "devii.notification.set",
"notification_reset": "devii.notification.reset",
"email_account_set": "email.account.set",
"email_account_delete": "email.account.delete",
"email_send": "email.send",
"email_delete_message": "email.message.delete",
"email_move_message": "email.message.move",
"email_mark": "email.message.flag",
"email_set_flags": "email.message.flag",
"telegram_send": "telegram.send",
}
_DEVII_CONTAINER_EVENTS = {
@@ -216,6 +226,19 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
"explicit confirmation, then call again with confirm=true."
)
if name == "email_account_delete":
label = str(arguments.get("account", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting the saved email account '{label}' removes its stored connection settings. "
"Ask the user to confirm explicitly, then call again with confirm=true."
)
if name == "email_delete_message":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting message {uid} on the remote mailbox is permanent unless a Trash folder is "
"given. Show the user the exact message, get explicit confirmation, then call again "
"with confirm=true."
)
if name in CONFIRM_REQUIRED:
return ToolInputError(
"This removes the item as a soft delete: it disappears from every surface and is only "
@@ -236,6 +259,7 @@ class Dispatcher:
avatar: AvatarController | None = None,
browser: Any = None,
is_admin: bool = False,
is_primary_admin: bool = False,
quota_provider: Any = None,
owner_kind: str = "guest",
owner_id: str = "",
@@ -250,6 +274,7 @@ class Dispatcher:
self._avatar = avatar
self._browser = browser
self._is_admin = is_admin
self._is_primary_admin = is_primary_admin
self._owner_kind = owner_kind
self._owner_id = owner_id
self._fetch = FetchController(settings)
@@ -272,6 +297,12 @@ class Dispatcher:
from ..ai_modifier import AiModifierController
self._ai_modifier = AiModifierController(owner_kind, owner_id)
from ..email import EmailController
self._email = EmailController(settings, owner_kind, owner_id)
from ..telegram import TelegramSendController
self._telegram = TelegramSendController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._behavior = behavior
self._read_files: set[tuple[str, str]] = set()
@@ -322,6 +353,11 @@ class Dispatcher:
"This information is restricted to administrators.",
tool=name,
)
if action.requires_primary_admin and not self._is_primary_admin:
raise AuthRequiredError(
"This tool is restricted to the primary administrator.",
tool=name,
)
guard = confirmation_error(name, arguments)
if guard is not None:
raise guard
@@ -427,6 +463,12 @@ class Dispatcher:
if action.handler == "ai_modifier":
return await self._ai_modifier.dispatch(action.name, arguments)
if action.handler == "email":
return await self._email.dispatch(action.name, arguments)
if action.handler == "telegram":
return await self._telegram.dispatch(action.name, arguments)
if action.handler == "behavior":
if self._behavior is None:
return error_result(
@@ -0,0 +1,258 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
REMOTE_NOTE = (
"This reaches the user's OWN external mailbox over IMAP/SMTP, not this platform. The "
"account must be configured first with email_account_set."
)
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,
)
ACCOUNT = arg(
"account",
"The label of the configured email account to use (see email_accounts_list).",
required=True,
)
FOLDER = arg("folder", "Mailbox folder; defaults to INBOX when omitted.")
UID = arg("uid", "The message UID returned by email_list_messages / email_search.", required=True)
FILTERS = (
arg("unseen", "Only unread messages.", kind="boolean"),
arg("seen", "Only read messages.", kind="boolean"),
arg("flagged", "Only flagged messages.", kind="boolean"),
arg("from", "Match the sender address or name."),
arg("subject", "Match text in the subject."),
arg("since", "Only messages on or after this date, formatted DD-Mon-YYYY (e.g. 01-Jan-2026)."),
arg("text", "Match text anywhere in the message."),
)
EMAIL_ACTIONS: tuple[Action, ...] = (
Action(
name="email_accounts_list",
method="LOCAL",
path="",
summary="List the user's configured email accounts (passwords are never returned)",
description="Returns each saved account's connection settings without the password.",
handler="email",
requires_auth=True,
read_only=True,
),
Action(
name="email_account_get",
method="LOCAL",
path="",
summary="Read one configured email account's connection settings",
description="Returns the account settings without the password (password_set indicates whether one is stored).",
handler="email",
requires_auth=True,
read_only=True,
params=(ACCOUNT,),
),
Action(
name="email_account_set",
method="LOCAL",
path="",
summary="Create or update an email account connection (IMAP + SMTP)",
description=(
"Saves an IMAP/SMTP connection under a label. Sensible defaults are applied when a "
"field is omitted: imap_port 993 with imap_ssl on, smtp_port 587 with smtp_starttls on, "
"from_address falls back to username. Provide host, username and password at minimum. "
+ REMOTE_NOTE
),
handler="email",
requires_auth=True,
params=(
ACCOUNT,
arg("imap_host", "IMAP server hostname (e.g. imap.gmail.com)."),
arg("imap_port", "IMAP port (default 993).", kind="integer"),
arg("imap_ssl", "Use implicit SSL/TLS for IMAP (default true).", kind="boolean"),
arg("imap_starttls", "Upgrade a plain IMAP connection with STARTTLS (default false).", kind="boolean"),
arg("smtp_host", "SMTP server hostname (e.g. smtp.gmail.com)."),
arg("smtp_port", "SMTP port (default 587).", kind="integer"),
arg("smtp_ssl", "Use implicit SSL/TLS for SMTP (default false; set true for port 465).", kind="boolean"),
arg("smtp_starttls", "Upgrade a plain SMTP connection with STARTTLS (default true).", kind="boolean"),
arg("username", "Login username (usually the full email address)."),
arg("password", "Login password or app-specific password."),
arg("from_address", "From address for sent mail (defaults to the username)."),
arg("from_name", "Display name for sent mail."),
),
),
Action(
name="email_account_delete",
method="LOCAL",
path="",
summary="Delete a saved email account connection (confirmation required)",
description="Removes the stored connection. Does not touch the remote mailbox.",
handler="email",
requires_auth=True,
params=(
ACCOUNT,
arg(
"confirm",
"Set to true only after the user has confirmed the deletion.",
required=True,
kind="boolean",
),
),
),
Action(
name="email_list_folders",
method="LOCAL",
path="",
summary="List the mailbox folders on the account",
description=REMOTE_NOTE,
handler="email",
requires_auth=True,
read_only=True,
params=(ACCOUNT,),
),
Action(
name="email_list_messages",
method="LOCAL",
path="",
summary="List messages in a folder, newest first, with optional filters",
description=(
"Returns message summaries (uid, from, subject, date, flags). Use the filters to narrow "
"the set and limit/offset to page. " + REMOTE_NOTE
),
handler="email",
requires_auth=True,
read_only=True,
params=(
ACCOUNT,
FOLDER,
*FILTERS,
arg("limit", "Maximum messages to return (1-100, default 25).", kind="integer"),
arg("offset", "Number of messages to skip for paging (default 0).", kind="integer"),
),
),
Action(
name="email_read_message",
method="LOCAL",
path="",
summary="Read the full body, headers and attachment list of one message",
description="Fetches the decoded text/html body and attachment metadata. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
read_only=True,
params=(ACCOUNT, FOLDER, UID),
),
Action(
name="email_search",
method="LOCAL",
path="",
summary="Search a folder by sender, subject, date, text or read/flag state",
description="Returns matching message summaries. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
read_only=True,
params=(
ACCOUNT,
FOLDER,
*FILTERS,
arg("limit", "Maximum messages to return (1-100, default 25).", kind="integer"),
),
),
Action(
name="email_mark",
method="LOCAL",
path="",
summary="Mark a message read, unread, flagged or unflagged",
description="Convenience wrapper over the IMAP \\Seen and \\Flagged flags. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("state", "One of: read, unread, flagged, unflagged.", required=True),
),
),
Action(
name="email_set_flags",
method="LOCAL",
path="",
summary="Add or remove arbitrary IMAP flags on a message",
description="For advanced use; prefer email_mark for read/flagged. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("flags", "Comma-separated IMAP flags (e.g. \\Seen, \\Flagged, \\Answered).", required=True),
arg("add", "true to add the flags, false to remove them (default true).", kind="boolean"),
),
),
Action(
name="email_move_message",
method="LOCAL",
path="",
summary="Move a message to another folder",
description="Copies the message to the destination folder and removes it from the source. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("destination", "Destination folder name.", required=True),
),
),
Action(
name="email_delete_message",
method="LOCAL",
path="",
summary="Delete a message (confirmation required)",
description=(
"Deletes the message. If 'trash' is given the message is moved there; otherwise it is "
"flagged \\Deleted and expunged. " + REMOTE_NOTE
),
handler="email",
requires_auth=True,
params=(
ACCOUNT,
FOLDER,
UID,
arg("trash", "Optional Trash folder to move the message into instead of expunging."),
arg(
"confirm",
"Set to true only after the user has confirmed the deletion.",
required=True,
kind="boolean",
),
),
),
Action(
name="email_send",
method="LOCAL",
path="",
summary="Send an email via the account's SMTP server",
description="Composes and sends a message. " + REMOTE_NOTE,
handler="email",
requires_auth=True,
params=(
ACCOUNT,
arg("to", "Recipient address or comma-separated list.", required=True),
arg("subject", "Email subject."),
arg("body", "Plain-text body."),
arg("cc", "CC recipients (comma-separated)."),
arg("bcc", "BCC recipients (comma-separated)."),
arg("html", "Optional HTML alternative body."),
arg("in_reply_to", "Optional Message-ID this email replies to."),
),
),
)
+8 -1
View File
@@ -27,6 +27,7 @@ class Action:
params: tuple[Param, ...] = ()
requires_auth: bool = True
requires_admin: bool = False
requires_primary_admin: bool = False
handler: Literal[
"http",
"login",
@@ -46,6 +47,8 @@ class Action:
"notification",
"behavior",
"virtual_tool",
"email",
"telegram",
] = "http"
freeform_body: bool = False
ajax: bool = False
@@ -115,11 +118,15 @@ class Catalog:
return [action.tool_schema() for action in self.actions]
def tool_schemas_for(
self, authenticated: bool, is_admin: bool = False
self,
authenticated: bool,
is_admin: bool = False,
is_primary_admin: bool = False,
) -> list[dict[str, Any]]:
return [
action.tool_schema()
for action in self.actions
if (authenticated or not action.requires_auth)
and (is_admin or not action.requires_admin)
and (is_primary_admin or not action.requires_primary_admin)
]
@@ -0,0 +1,31 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
TELEGRAM_ACTIONS: tuple[Action, ...] = (
Action(
name="telegram_send",
method="LOCAL",
path="",
summary="Send a message to the user's connected Telegram chat",
description=(
"Delivers a markdown message to the signed-in user's paired Telegram account. Use this "
"to push notifications, reminders, or results to the user on Telegram, including from a "
"scheduled task. Requires the user to have paired Telegram from their profile and the "
"Telegram bot service to be running. Markdown is rendered to Telegram formatting and "
"long messages are split automatically."
),
handler="telegram",
requires_auth=True,
params=(
Param(
name="text",
location="body",
description="The markdown message to deliver to the user's Telegram chat.",
required=True,
),
),
),
)
+32 -4
View File
@@ -53,8 +53,25 @@ SYSTEM_PROMPT = (
"highlight_element, show_toast, scroll_to_element and clear_highlights guide them with live, "
"on-screen tutorials; run_js executes JavaScript and returns a value. The session and this "
"conversation persist and reconnect automatically across navigation. "
"Confirm destructive actions with the user first. Schedule autonomous work with "
"create_task and related tools. All times are UTC.\n\n"
"Confirm destructive actions with the user first.\n\n"
"REMINDERS AND SCHEDULED TASKS\n"
"When the user asks you to remind, tell, notify, ping, wake, or alert them after a delay or "
"at a time ('remind me in 40 seconds', 'tell me to go upstairs in 2 minutes', 'every "
"morning at 9 post the news'), DO NOT answer or perform the instruction immediately. CREATE "
"A SCHEDULED TASK with create_task so it fires later, then confirm the schedule. A request "
"with a delay or a time is never something to do right now - it is something to schedule. "
"Choose the schedule precisely: a RELATIVE request ('in N seconds/minutes/hours') uses "
"kind='once' with delay_seconds; an ABSOLUTE wall-clock time ('at 3pm', 'tomorrow 09:00') "
"uses kind='once' with run_at in UTC, converting from the user's local timezone shown in the "
"CURRENT TIME section; a RECURRING request ('every day', 'each Monday') uses kind='cron'. "
"The prompt you store is what a fresh agent runs when the task fires and it has no other "
"context, so write a complete self-contained instruction (for a reminder: 'Tell the user to "
"go upstairs now.'). For any reminder, pass notify=true so the user is sent an in-app "
"notification and toast carrying your message even if the Devii terminal is closed. After "
"creating it, tell the user exactly when it will fire, in their local time. Manage existing "
"reminders with list_tasks, get_task, update_task, run_task_now, and delete_task. Times in "
"task records are UTC; the CURRENT TIME section gives you the current UTC time and the "
"user's local time and timezone for conversion.\n\n"
"DRIVING THE BROWSER (the reliable flow)\n"
"To act on the user's screen, follow this loop instead of guessing selectors or writing run_js. "
"(1) get_page_context to see where they are, the open modal, and visible toasts. "
@@ -175,6 +192,15 @@ SYSTEM_PROMPT = (
"information the platform cannot provide and the user wants it. Never use them to answer questions "
"about this DevPlace instance, its users, posts, settings, or metrics - those have dedicated "
"platform tools. When platform tools can serve the request, do not call rsearch.\n\n"
"EMAIL TOOLS (email_*)\n"
"The email_* tools connect to the user's OWN external mailbox over IMAP/SMTP, not this "
"platform, and are only available to signed-in users. The connection must be configured first "
"with email_account_set under a label (host, port, username, password; sensible defaults fill "
"the rest), and every other email tool takes that label as its 'account'. Use email_list_messages "
"/ email_search / email_read_message to read, email_mark / email_move_message to organise, and "
"email_send to send. Sending is a real outbound action: show the user the recipients, subject and "
"body and get their go-ahead before calling email_send. Deleting a message or removing a saved "
"account is confirmation-gated (call again with confirm=true only after the user agrees).\n\n"
"CUSTOMIZATION (CSS AND JAVASCRIPT)\n"
"The user can permanently customize the LOOK (custom CSS) and BEHAVIOUR (custom JavaScript) of "
"the site through the customize_* tools. Every customization is scoped either to the current "
@@ -286,7 +312,7 @@ class Agent:
{"role": "system", "content": system_prompt}
]
async def respond(self, user_text: str) -> str:
async def respond(self, user_text: Any) -> str:
self._inject_recalled_lessons(user_text)
self._messages.append({"role": "user", "content": user_text})
state = AgentState()
@@ -305,7 +331,9 @@ class Agent:
chunk_store=self._chunk_store,
)
def _inject_recalled_lessons(self, user_text: str) -> None:
def _inject_recalled_lessons(self, user_text: Any) -> None:
if not isinstance(user_text, str):
return
if self._lessons is None or self._lessons.count() == 0:
return
hits = self._lessons.search(user_text, k=self._settings.recall_top_k)
+10 -2
View File
@@ -167,9 +167,17 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
chunk_store = ChunkStore()
agentic = AgenticController(lessons, settings)
dispatcher = Dispatcher(
CATALOG, client, settings, controller, agentic, is_admin=True
CATALOG,
client,
settings,
controller,
agentic,
is_admin=True,
is_primary_admin=True,
)
tools = CATALOG.tool_schemas_for(
client.authenticated, is_admin=True, is_primary_admin=True
)
tools = CATALOG.tool_schemas_for(client.authenticated, is_admin=True)
agentic.bind(
llm=llm,
dispatcher=dispatcher,
+14
View File
@@ -38,6 +38,7 @@ DEFAULT_FETCH_TIMEOUT_SECONDS = 300.0
DEFAULT_FETCH_MAX_BYTES = 8_000_000
DEFAULT_RSEARCH_URL = "https://rsearch.app.molodetz.nl"
DEFAULT_RSEARCH_TIMEOUT_SECONDS = 300.0
DEFAULT_EMAIL_TIMEOUT_SECONDS = 30.0
@dataclass(frozen=True)
@@ -72,6 +73,8 @@ class Settings:
rsearch_enabled: bool
rsearch_url: str
rsearch_timeout_seconds: float
email_enabled: bool
email_timeout_seconds: float
daily_limit_usd: float
@@ -152,6 +155,11 @@ def load_settings() -> Settings:
rsearch_timeout_seconds=float(
os.environ.get("DEVII_RSEARCH_TIMEOUT", DEFAULT_RSEARCH_TIMEOUT_SECONDS)
),
email_enabled=os.environ.get("DEVII_EMAIL_ENABLED", "1").lower()
in ("1", "true", "yes", "on"),
email_timeout_seconds=float(
os.environ.get("DEVII_EMAIL_TIMEOUT", DEFAULT_EMAIL_TIMEOUT_SECONDS)
),
daily_limit_usd=0.0,
)
@@ -176,6 +184,8 @@ FIELD_FETCH_TIMEOUT = "devii_fetch_timeout"
FIELD_RSEARCH_ENABLED = "devii_rsearch_enabled"
FIELD_RSEARCH_URL = "devii_rsearch_url"
FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
FIELD_EMAIL_ENABLED = "devii_email_enabled"
FIELD_EMAIL_TIMEOUT = "devii_email_timeout"
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", str(DEVII_LESSONS_DB))
@@ -234,5 +244,9 @@ def build_settings(
rsearch_timeout_seconds=float(
config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS
),
email_enabled=bool(config.get(FIELD_EMAIL_ENABLED, True)),
email_timeout_seconds=float(
config.get(FIELD_EMAIL_TIMEOUT) or DEFAULT_EMAIL_TIMEOUT_SECONDS
),
daily_limit_usd=effective_daily_limit(config, owner_kind, is_admin),
)
+9 -1
View File
@@ -1,13 +1,21 @@
# retoor <retoor@molodetz.nl>
from .controller import CostController
from .tracker import CostTracker, Pricing, record_usage, reset_tracker, set_tracker
from .tracker import (
CostTracker,
Pricing,
record_cost,
record_usage,
reset_tracker,
set_tracker,
)
__all__ = [
"CostTracker",
"Pricing",
"CostController",
"record_usage",
"record_cost",
"set_tracker",
"reset_tracker",
]
+17 -1
View File
@@ -48,6 +48,12 @@ class CostTracker:
cache_hit_tokens: int = 0
cache_miss_tokens: int = 0
reasoning_tokens: int = 0
native_cost_usd: float = 0.0
has_native: bool = False
def record_native(self, cost_usd: float) -> None:
self.native_cost_usd += float(cost_usd or 0.0)
self.has_native = True
def record(self, usage: Optional[dict[str, Any]]) -> None:
if not usage:
@@ -91,7 +97,11 @@ class CostTracker:
self.cache_miss_tokens / PER_MILLION * self.pricing.cache_miss_per_m
)
output = self.completion_tokens / PER_MILLION * self.pricing.output_per_m
total = cache_hit + cache_miss + output
total = (
self.native_cost_usd
if self.has_native
else (cache_hit + cache_miss + output)
)
return {
"cache_hit_input": round(cache_hit, 8),
"cache_miss_input": round(cache_miss, 8),
@@ -163,3 +173,9 @@ def record_usage(usage: Optional[dict[str, Any]]) -> None:
tracker = _active_tracker.get()
if tracker is not None:
tracker.record(usage)
def record_cost(cost_usd: float) -> None:
tracker = _active_tracker.get()
if tracker is not None:
tracker.record_native(cost_usd)
@@ -0,0 +1,7 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .controller import EmailController
__all__ = ["EmailController"]
@@ -0,0 +1,360 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from devplacepy import database
from devplacepy.services.email import EmailAccount, EmailClient, EmailError
from devplacepy.services.email.client import imap_quote
from ..config import Settings
from ..errors import AuthRequiredError, ToolInputError, UpstreamError
logger = logging.getLogger("devii.email")
ACCOUNT_FIELDS = (
"imap_host",
"imap_port",
"imap_ssl",
"imap_starttls",
"smtp_host",
"smtp_port",
"smtp_ssl",
"smtp_starttls",
"username",
"password",
"from_address",
"from_name",
)
BOOLEAN_FIELDS = ("imap_ssl", "imap_starttls", "smtp_ssl", "smtp_starttls")
INTEGER_FIELDS = ("imap_port", "smtp_port")
MARK_FLAGS = {
"read": ("\\Seen", True),
"unread": ("\\Seen", False),
"flagged": ("\\Flagged", True),
"unflagged": ("\\Flagged", False),
}
def _flag(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _text(arguments: dict[str, Any], key: str) -> str:
return str(arguments.get(key, "") or "").strip()
def _address_list(value: Any) -> list[str]:
if isinstance(value, (list, tuple)):
items = [str(item).strip() for item in value]
else:
items = [chunk.strip() for chunk in str(value or "").split(",")]
return [item for item in items if item]
class EmailController:
def __init__(
self, settings: Settings, owner_kind: str = "guest", owner_id: str = ""
) -> None:
self._settings = settings
self._owner_kind = owner_kind
self._owner_id = owner_id
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if not getattr(self._settings, "email_enabled", True):
raise ToolInputError(
"Email tools are disabled by the administrator."
)
if self._owner_kind != "user":
raise AuthRequiredError(
"Email tools are only available to signed-in users.", tool=name
)
if name == "email_accounts_list":
return self._accounts_list()
if name == "email_account_get":
return self._account_get(arguments)
if name == "email_account_set":
return self._account_set(arguments)
if name == "email_account_delete":
return self._account_delete(arguments)
if name == "email_list_folders":
return await self._list_folders(arguments)
if name == "email_list_messages":
return await self._list_messages(arguments)
if name == "email_read_message":
return await self._read_message(arguments)
if name == "email_search":
return await self._search(arguments)
if name == "email_set_flags":
return await self._set_flags(arguments)
if name == "email_mark":
return await self._mark(arguments)
if name == "email_move_message":
return await self._move(arguments)
if name == "email_delete_message":
return await self._delete(arguments)
if name == "email_send":
return await self._send(arguments)
raise ToolInputError(f"Unknown email tool: {name}")
def _label(self, arguments: dict[str, Any]) -> str:
label = _text(arguments, "account")
if not label:
raise ToolInputError("An 'account' label is required.")
return label
def _client(self, arguments: dict[str, Any]) -> EmailClient:
label = self._label(arguments)
row = database.get_email_account(self._owner_kind, self._owner_id, label)
if not row:
raise ToolInputError(
f"No email account named '{label}'. Add it with email_account_set first."
)
return EmailClient(
EmailAccount.from_row(row),
timeout=getattr(self._settings, "email_timeout_seconds", 30.0),
)
def _public_account(self, row: dict[str, Any]) -> dict[str, Any]:
return {
"label": row.get("label"),
"imap_host": row.get("imap_host"),
"imap_port": row.get("imap_port"),
"imap_ssl": bool(row.get("imap_ssl")),
"imap_starttls": bool(row.get("imap_starttls")),
"smtp_host": row.get("smtp_host"),
"smtp_port": row.get("smtp_port"),
"smtp_ssl": bool(row.get("smtp_ssl")),
"smtp_starttls": bool(row.get("smtp_starttls")),
"username": row.get("username"),
"password_set": bool(row.get("password")),
"from_address": row.get("from_address"),
"from_name": row.get("from_name"),
"updated_at": row.get("updated_at"),
}
def _accounts_list(self) -> str:
rows = database.list_email_accounts(self._owner_kind, self._owner_id)
return json.dumps(
{
"status": "success",
"accounts": [self._public_account(row) for row in rows],
},
ensure_ascii=False,
)
def _account_get(self, arguments: dict[str, Any]) -> str:
label = self._label(arguments)
row = database.get_email_account(self._owner_kind, self._owner_id, label)
if not row:
raise ToolInputError(f"No email account named '{label}'.")
return json.dumps(
{"status": "success", "account": self._public_account(row)},
ensure_ascii=False,
)
def _account_set(self, arguments: dict[str, Any]) -> str:
label = self._label(arguments)
fields: dict[str, Any] = {}
for key in ACCOUNT_FIELDS:
if key not in arguments or arguments[key] is None:
continue
value = arguments[key]
if key in BOOLEAN_FIELDS:
fields[key] = 1 if _flag(value) else 0
elif key in INTEGER_FIELDS:
try:
fields[key] = int(value)
except (TypeError, ValueError):
raise ToolInputError(f"'{key}' must be an integer port number.")
else:
fields[key] = str(value).strip()
row = database.set_email_account(
self._owner_kind, self._owner_id, label, fields
)
return json.dumps(
{
"status": "success",
"saved": True,
"account": self._public_account(row),
},
ensure_ascii=False,
)
def _account_delete(self, arguments: dict[str, Any]) -> str:
label = self._label(arguments)
removed = database.delete_email_account(
self._owner_kind,
self._owner_id,
label,
deleted_by=f"user:{self._owner_id}",
)
return json.dumps(
{"status": "success", "removed": int(removed), "account": label},
ensure_ascii=False,
)
def _folder(self, arguments: dict[str, Any]) -> str:
return _text(arguments, "folder") or "INBOX"
def _limit(self, arguments: dict[str, Any], default: int = 25) -> int:
try:
limit = int(arguments.get("limit", default))
except (TypeError, ValueError):
limit = default
return max(1, min(limit, 100))
def _offset(self, arguments: dict[str, Any]) -> int:
try:
offset = int(arguments.get("offset", 0))
except (TypeError, ValueError):
offset = 0
return max(0, offset)
def _criteria(self, arguments: dict[str, Any]) -> list[str]:
criteria: list[str] = []
if _flag(arguments.get("unseen")):
criteria.append("UNSEEN")
if _flag(arguments.get("seen")):
criteria.append("SEEN")
if _flag(arguments.get("flagged")):
criteria.append("FLAGGED")
sender = _text(arguments, "from")
if sender:
criteria += ["FROM", imap_quote(sender)]
subject = _text(arguments, "subject")
if subject:
criteria += ["SUBJECT", imap_quote(subject)]
since = _text(arguments, "since")
if since:
criteria += ["SINCE", since]
text = _text(arguments, "text")
if text:
criteria += ["TEXT", imap_quote(text)]
return criteria
async def _run(self, func: Any, *args: Any) -> Any:
try:
return await asyncio.to_thread(func, *args)
except EmailError as exc:
if exc.kind in ("config", "not_found", "blocked"):
raise ToolInputError(exc.message) from exc
raise UpstreamError(exc.message) from exc
def _ok(self, payload: dict[str, Any]) -> str:
return json.dumps({"status": "success", **payload}, ensure_ascii=False)
async def _list_folders(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
folders = await self._run(client.list_folders)
return self._ok({"folders": folders})
async def _list_messages(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
result = await self._run(
client.list_messages,
self._folder(arguments),
self._criteria(arguments),
self._limit(arguments),
self._offset(arguments),
)
return self._ok(result)
async def _read_message(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
uid = _text(arguments, "uid")
if not uid:
raise ToolInputError("A message 'uid' is required.")
result = await self._run(
client.read_message, self._folder(arguments), uid
)
return self._ok({"message": result})
async def _search(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
result = await self._run(
client.search,
self._folder(arguments),
self._criteria(arguments),
self._limit(arguments),
)
return self._ok(result)
async def _set_flags(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
uid = _text(arguments, "uid")
if not uid:
raise ToolInputError("A message 'uid' is required.")
flags = _address_list(arguments.get("flags"))
if not flags:
raise ToolInputError("At least one flag is required (e.g. \\Seen).")
add = _flag(arguments.get("add", True)) if "add" in arguments else True
result = await self._run(
client.set_flags, self._folder(arguments), uid, flags, add
)
return self._ok(result)
async def _mark(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
uid = _text(arguments, "uid")
if not uid:
raise ToolInputError("A message 'uid' is required.")
state = _text(arguments, "state").lower()
if state not in MARK_FLAGS:
raise ToolInputError(
"'state' must be one of: read, unread, flagged, unflagged."
)
flag, add = MARK_FLAGS[state]
result = await self._run(
client.set_flags, self._folder(arguments), uid, [flag], add
)
return self._ok({**result, "state": state})
async def _move(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
uid = _text(arguments, "uid")
destination = _text(arguments, "destination")
if not uid or not destination:
raise ToolInputError("Both 'uid' and 'destination' are required.")
result = await self._run(
client.move_message, self._folder(arguments), uid, destination
)
return self._ok(result)
async def _delete(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
uid = _text(arguments, "uid")
if not uid:
raise ToolInputError("A message 'uid' is required.")
trash = _text(arguments, "trash") or None
result = await self._run(
client.delete_message, self._folder(arguments), uid, trash
)
return self._ok(result)
async def _send(self, arguments: dict[str, Any]) -> str:
client = self._client(arguments)
to = _address_list(arguments.get("to"))
if not to:
raise ToolInputError("At least one 'to' recipient is required.")
subject = _text(arguments, "subject")
body = str(arguments.get("body", "") or "")
if not subject and not body:
raise ToolInputError("A 'subject' or 'body' is required.")
result = await self._run(
client.send_message,
to,
subject,
body,
_address_list(arguments.get("cc")),
_address_list(arguments.get("bcc")),
str(arguments.get("html", "") or "") or None,
_text(arguments, "in_reply_to") or None,
)
return self._ok(result)
+8 -2
View File
@@ -44,6 +44,7 @@ class DeviiHub:
api_key: str,
base_url: str,
is_admin: bool = False,
is_primary_admin: bool = False,
channel: str = "main",
) -> DeviiSession:
key = (owner_kind, owner_id, channel)
@@ -57,7 +58,11 @@ class DeviiHub:
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
# (and a Docii reflection never pollutes the user's Devii memory). Only the conversation
# thread persists, keyed per channel in the shared ConversationStore.
owned_db = db if (owner_kind == "user" and channel == "main") else memory_db()
owned_db = (
db
if (owner_kind == "user" and channel in ("main", "telegram"))
else memory_db()
)
task_store = TaskStore(owned_db, owner_kind, owner_id)
lessons = LessonStore(owned_db, owner_kind, owner_id)
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
@@ -75,6 +80,7 @@ class DeviiHub:
behavior_store,
self._stores,
is_admin=is_admin,
is_primary_admin=is_primary_admin,
channel=channel,
)
if owner_kind == "user":
@@ -105,7 +111,7 @@ class DeviiHub:
async def gc_idle(self) -> int:
removed = 0
for key, session in list(self._sessions.items()):
if session.connection_count == 0:
if session.connection_count == 0 and not session.has_pending_tasks():
await session.aclose()
del self._sessions[key]
removed += 1
+14 -1
View File
@@ -9,7 +9,7 @@ import httpx
from devplacepy import stealth
from .config import Settings
from .cost import record_usage
from .cost import record_cost, record_usage
from .errors import LLMError
logger = logging.getLogger("devii.llm")
@@ -67,6 +67,7 @@ class LLMClient:
raise LLMError("Model response contained no message.", body=str(data)[:500])
record_usage(data.get("usage"))
self._record_native_cost(response)
logger.debug(
"LLM response received (tool_calls=%s)", bool(message.get("tool_calls"))
)
@@ -94,6 +95,7 @@ class LLMClient:
except (ValueError, KeyError, IndexError) as exc:
raise LLMError("Model endpoint returned an unexpected response.") from exc
record_usage(data.get("usage"))
self._record_native_cost(response)
return content
async def summarize(self, text: str) -> str:
@@ -124,8 +126,19 @@ class LLMClient:
"Model summarization returned an unexpected response."
) from exc
record_usage(data.get("usage"))
self._record_native_cost(response)
return content
@staticmethod
def _record_native_cost(response: httpx.Response) -> None:
raw = response.headers.get("X-Gateway-Cost-USD")
if raw is None:
return
try:
record_cost(float(raw))
except (TypeError, ValueError):
return
@staticmethod
def _reason(response: httpx.Response) -> str:
try:
+4
View File
@@ -13,10 +13,12 @@ from .actions.container_actions import CONTAINER_ACTIONS
from .actions.cost_actions import COST_ACTIONS
from .actions.customization_actions import CUSTOMIZATION_ACTIONS
from .actions.docs_actions import DOCS_ACTIONS
from .actions.email_actions import EMAIL_ACTIONS
from .actions.fetch_actions import FETCH_ACTIONS
from .actions.notification_actions import NOTIFICATION_ACTIONS
from .actions.rsearch_actions import RSEARCH_ACTIONS
from .actions.spec import Catalog
from .actions.telegram_actions import TELEGRAM_ACTIONS
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
from .agentic.actions import AGENTIC_ACTIONS
from .tasks.actions import TASK_ACTIONS
@@ -38,6 +40,8 @@ CATALOG = Catalog(
+ NOTIFICATION_ACTIONS
+ AI_CORRECTION_ACTIONS
+ AI_MODIFIER_ACTIONS
+ EMAIL_ACTIONS
+ TELEGRAM_ACTIONS
+ VIRTUAL_TOOL_ACTIONS
)
+66 -2
View File
@@ -216,6 +216,25 @@ class DeviiService(BaseService):
"minutes, so this is generous by default. Minimum five minutes.",
group="Web search",
),
ConfigField(
config.FIELD_EMAIL_ENABLED,
"Enable email tools",
type="bool",
default=True,
help="Allow the email_* tools (configure accounts, list/read/search/flag/move/delete "
"messages, send mail) for signed-in users. These connect to the user's own external "
"mailbox over IMAP/SMTP, not this platform. When off, those tools are refused.",
group="Email",
),
ConfigField(
config.FIELD_EMAIL_TIMEOUT,
"Email timeout (seconds)",
type="float",
default=config.DEFAULT_EMAIL_TIMEOUT_SECONDS,
minimum=1,
help="Connection/read timeout for IMAP and SMTP calls.",
group="Email",
),
]
def __init__(self):
@@ -275,6 +294,12 @@ class DeviiService(BaseService):
def spent_24h(self, owner_kind: str, owner_id: str) -> float:
return self.hub().ledger.spent_24h(owner_kind, owner_id)
def quota_exceeded(
self, owner_kind: str, owner_id: str, is_admin: bool = False
) -> bool:
limit = self.daily_limit_for(owner_kind, is_admin)
return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit
def reset_quota(self, owner_kind: str, owner_id: str) -> int:
return self.hub().ledger.reset(owner_kind, owner_id)
@@ -288,13 +313,52 @@ class DeviiService(BaseService):
if not self.is_enabled():
return
hub = self.hub()
ensured = self._ensure_task_schedulers()
pruned = hub.ledger.prune(48)
removed = await hub.gc_idle()
if pruned or removed:
if pruned or removed or ensured:
self.log(
f"Housekeeping: pruned {pruned} ledger rows, closed {removed} idle sessions"
f"Housekeeping: ensured {ensured} task scheduler(s), pruned {pruned} "
f"ledger rows, closed {removed} idle sessions"
)
def _ensure_task_schedulers(self) -> int:
from devplacepy.database import db
from devplacepy.utils import is_admin, is_primary_admin
from .tasks.store import pending_owner_ids
try:
owner_ids = pending_owner_ids(db)
except Exception: # noqa: BLE001 - a bad read must not stop housekeeping
logger.exception("Failed to scan for owners with pending Devii tasks")
return 0
if not owner_ids:
return 0
users = db["users"] if "users" in db.tables else None
if users is None:
return 0
ensured = 0
base_url = self.instance_base_url()
for owner_id in owner_ids:
user = users.find_one(uid=owner_id)
if not user:
continue
session = self.hub().get_or_create(
"user",
owner_id,
user.get("username", ""),
user.get("api_key", ""),
base_url,
is_admin=is_admin(user),
is_primary_admin=is_primary_admin(user),
channel="main",
)
session.set_timezone(user.get("timezone") or "")
session.ensure_scheduler_started()
ensured += 1
return ensured
async def on_disable(self) -> None:
if self._hub is not None:
await self._hub.aclose()
+188 -20
View File
@@ -85,6 +85,7 @@ class DeviiSession:
behavior_store: Any,
stores: dict[str, Any],
is_admin: bool = False,
is_primary_admin: bool = False,
channel: str = "main",
) -> None:
self.owner_kind = owner_kind
@@ -93,7 +94,10 @@ class DeviiSession:
self.username = username
self.settings = settings
self.is_admin = is_admin
self.is_primary_admin = is_primary_admin
self.persist_conversation = owner_kind == "user"
self._timezone: str = ""
self._tz_offset_minutes: int | None = None
self._llm = llm
self._lessons = lessons
self.client = PlatformClient(
@@ -123,6 +127,7 @@ class DeviiSession:
avatar=self.avatar,
browser=self.browser,
is_admin=is_admin,
is_primary_admin=is_primary_admin,
quota_provider=self._quota_snapshot,
owner_kind=owner_kind,
owner_id=owner_id,
@@ -201,6 +206,12 @@ class DeviiSession:
def _make_executor(self) -> Any:
async def execute(prompt: str) -> str:
async with self._lock:
turn_id = uuid_utils.uuid7().hex
started_at = _now_iso()
self._turn_tool_calls = 0
before = self._cost_snapshot()
reply = ""
error = ""
worker = Agent(
self.settings,
self._llm,
@@ -212,7 +223,16 @@ class DeviiSession:
chunk_store=self.chunks,
system_prompt=self._compose_system_prompt(),
)
return await worker.respond(prompt)
try:
reply = await worker.respond(prompt)
return reply
except Exception as exc: # noqa: BLE001 - recorded, then re-raised to the scheduler
error = str(exc)
raise
finally:
self._record_spend(
turn_id, started_at, prompt, reply, error, before
)
return execute
@@ -228,9 +248,7 @@ class DeviiSession:
self._disconnected.clear()
self.avatar.bind(self._avatar_request)
self.browser.bind(self._client_request)
if not self._started and self.channel == "main":
self.scheduler.start()
self._started = True
self.ensure_scheduler_started()
if self._buffer:
pending = self._buffer
self._buffer = []
@@ -257,6 +275,40 @@ class DeviiSession:
len(self._conns),
)
def ensure_scheduler_started(self) -> None:
if not self._started and self.channel == "main":
self.scheduler.start()
self._started = True
def has_pending_tasks(self) -> bool:
if self.channel != "main":
return False
try:
return self.store.has_pending()
except Exception: # noqa: BLE001 - never block GC on a store read
return False
def set_clientinfo(self, timezone_name: str, offset_minutes: int | None) -> None:
if timezone_name:
self._timezone = timezone_name
if offset_minutes is not None:
self._tz_offset_minutes = int(offset_minutes)
if self.owner_kind == "user" and timezone_name:
from devplacepy.database import set_user_timezone
try:
set_user_timezone(self.owner_id, timezone_name)
except Exception: # noqa: BLE001 - persistence must not break the socket
logger.exception(
"Failed to persist timezone for %s/%s",
self.owner_kind,
self.owner_id,
)
def set_timezone(self, timezone_name: str) -> None:
if timezone_name:
self._timezone = timezone_name
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
meta = self._conn_meta.get(ws)
if meta is not None:
@@ -322,9 +374,9 @@ class DeviiSession:
)
await self._emit({"type": "clear"}, buffer=False)
def spawn_turn(self, text: str) -> None:
def spawn_turn(self, content: Any, audit_text: str | None = None) -> None:
epoch = self._turn_epoch
task = asyncio.create_task(self._run_turn(text, epoch))
task = asyncio.create_task(self._run_turn(content, epoch, audit_text))
self._turns.add(task)
task.add_done_callback(self._turns.discard)
@@ -373,7 +425,9 @@ class DeviiSession:
)
await self._emit({"type": "status", "text": "Stopped."}, buffer=False)
async def _run_turn(self, text: str, epoch: int) -> None:
async def _run_turn(
self, content: Any, epoch: int, audit_text: str | None = None
) -> None:
turn_id = uuid_utils.uuid7().hex
started_at = _now_iso()
before = self._cost_snapshot()
@@ -381,13 +435,19 @@ class DeviiSession:
reply = ""
error = ""
cancelled = False
prompt_text = audit_text if audit_text is not None else (
content if isinstance(content, str) else "[image]"
)
try:
async with self._lock:
self._refresh_tools()
self._refresh_system_prompt()
if self.channel == "docs":
await self._docs_topic_gate(text)
reply = await self.agent.respond(text)
await self._docs_topic_gate(prompt_text)
before = self._cost_snapshot()
reply = await self.agent.respond(content)
if not isinstance(content, str):
self._redact_last_user_message(prompt_text)
if epoch == self._turn_epoch:
await self._emit({"type": "reply", "text": reply}, buffer=True)
except asyncio.CancelledError:
@@ -399,15 +459,18 @@ class DeviiSession:
if epoch == self._turn_epoch:
await self._emit({"type": "error", "text": error}, buffer=True)
finally:
self._record_spend(turn_id, started_at, prompt_text, reply, error, before)
if not cancelled and epoch == self._turn_epoch:
self._record_turn(turn_id, started_at, text, reply, error, before)
self._persist_history()
if self.owner_kind == "user" and self.channel != "docs" and not error:
from devplacepy.utils import track_action
track_action(self.owner_id, "devii")
def _builtin_tools(self) -> list[dict[str, Any]]:
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
schemas = CATALOG.tool_schemas_for(
self.client.authenticated, self.is_admin, self.is_primary_admin
)
if self.channel == "docs":
return [
s
@@ -428,7 +491,55 @@ class DeviiSession:
return self._system_prompt
body = self._behavior_store.text().strip()
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
return f"{self._system_prompt}\n\n{section}"
return f"{self._system_prompt}\n\n{self._clock_line()}\n\n{section}"
def _clock_line(self) -> str:
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc).replace(microsecond=0)
tz_name = self._timezone or self._stored_timezone()
local = ""
if tz_name:
try:
from zoneinfo import ZoneInfo
local_now = now.astimezone(ZoneInfo(tz_name))
offset = local_now.utcoffset()
offset_text = _format_offset(offset)
local = (
f" The user's local time is {local_now.strftime('%Y-%m-%d %H:%M:%S')} "
f"({tz_name}, UTC{offset_text})."
)
except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only
local = ""
if not local and self._tz_offset_minutes is not None:
offset = timedelta(minutes=self._tz_offset_minutes)
local = (
f" The user's local time is "
f"{(now + offset).strftime('%Y-%m-%d %H:%M:%S')} "
f"(UTC{_format_offset(offset)})."
)
return (
f"# CURRENT TIME\n"
f"The current UTC time is {now.strftime('%Y-%m-%dT%H:%M:%S')}Z.{local} "
"When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), "
"interpret it in the user's local timezone and convert it to UTC for the run_at field. "
"For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds "
"instead and do not compute an absolute time."
)
def _stored_timezone(self) -> str:
if self.owner_kind != "user":
return ""
try:
from devplacepy.database import db
if "users" not in db.tables:
return ""
row = db["users"].find_one(uid=self.owner_id)
return (row or {}).get("timezone") or ""
except Exception: # noqa: BLE001 - clock line is best-effort
return ""
def _refresh_system_prompt(self) -> None:
messages = self.agent._messages
@@ -506,7 +617,31 @@ class DeviiSession:
"cost_usd": self.cost.cost_usd()["total"],
}
def _record_turn(self, turn_id, started_at, prompt, reply, error, before) -> None:
def _persist_history(self) -> None:
if not self.persist_conversation:
return
try:
self._conv.save(
self.owner_kind,
self.owner_id,
self.agent._messages,
self.channel,
)
except Exception: # noqa: BLE001 - persistence must never break a turn
logger.exception(
"Failed to persist conversation for %s/%s",
self.owner_kind,
self.owner_id,
)
def _redact_last_user_message(self, text: str) -> None:
messages = self.agent._messages
for message in reversed(messages):
if message.get("role") == "user":
message["content"] = text
return
def _record_spend(self, turn_id, started_at, prompt, reply, error, before) -> None:
after = self._cost_snapshot()
usage = {
"prompt_tokens": after["prompt_tokens"] - before["prompt_tokens"],
@@ -517,14 +652,13 @@ class DeviiSession:
- before["cache_miss_tokens"],
}
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
if (
cost_delta <= 0
and usage["prompt_tokens"] <= 0
and usage["completion_tokens"] <= 0
):
return
try:
if self.persist_conversation:
self._conv.save(
self.owner_kind,
self.owner_id,
self.agent._messages,
self.channel,
)
self._ledger.record(
self.owner_kind,
self.owner_id,
@@ -690,6 +824,31 @@ class DeviiSession:
"payload": payload[:RESULT_NOTICE_CHARS],
}
asyncio.create_task(self._emit(message, buffer=True))
if kind in ("done", "finished") and row.get("notify"):
self._deliver_reminder(row, payload)
def _deliver_reminder(self, row: dict[str, Any], payload: str) -> None:
if self.owner_kind != "user" or not self.owner_id:
return
text = (payload or "").strip() or (
row.get("label") or "Your scheduled reminder fired."
)
try:
from devplacepy.utils import create_notification
create_notification(
self.owner_id,
"reminder",
text[:500],
row.get("uid", ""),
target_url="/devii",
)
except Exception: # noqa: BLE001 - a reminder notification must never crash the scheduler
logger.exception(
"Failed to deliver reminder notification for %s/%s",
self.owner_kind,
self.owner_id,
)
NON_ADMIN_COST_RULE = (
@@ -792,3 +951,12 @@ def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
def _format_offset(offset: Any) -> str:
if offset is None:
return "+00:00"
total_minutes = int(offset.total_seconds() // 60)
sign = "+" if total_minutes >= 0 else "-"
total_minutes = abs(total_minutes)
return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
+1 -1
View File
@@ -24,7 +24,7 @@ def _now() -> datetime:
def _iso(moment: datetime) -> str:
return moment.isoformat()
return moment.isoformat(timespec="microseconds")
class ConversationStore:
@@ -77,6 +77,13 @@ TASK_ACTIONS: tuple[Action, ...] = (
required=True,
),
field("label", "Optional short human-readable label for the task."),
field(
"notify",
"Set true for a REMINDER: when the task fires, the user is sent an in-app "
"notification and live toast carrying the result, so it reaches them even when "
"the Devii terminal is closed. Leave false for silent background automation.",
kind="boolean",
),
*SCHEDULE_FIELDS,
),
),
@@ -116,6 +123,11 @@ TASK_ACTIONS: tuple[Action, ...] = (
field("prompt", "New prompt."),
field("label", "New label."),
field("enabled", "Enable or disable the task.", kind="boolean"),
field(
"notify",
"Enable or disable the in-app reminder notification when this task fires.",
kind="boolean",
),
field("kind", "New schedule type when rescheduling."),
field("run_at", "New absolute run time for kind=once."),
field("delay_seconds", "New relative delay for kind=once.", kind="integer"),
@@ -51,6 +51,8 @@ def _serialize(row: dict[str, Any], preview: bool) -> dict[str, Any]:
"every_seconds": row.get("every_seconds"),
"cron": row.get("cron"),
"run_at": row.get("run_at"),
"notify": bool(row.get("notify")),
"tz": row.get("tz") or None,
}
last_error = row.get("last_error")
if last_error:
@@ -98,6 +100,8 @@ class TaskController:
"run_count": 0,
"last_result": None,
"last_error": None,
"notify": 1 if _as_bool(arguments.get("notify"), default=False) else 0,
"tz": (arguments.get("tz") or "").strip() or None,
**schedule.columns(),
}
self._store.create(record)
@@ -137,6 +141,10 @@ class TaskController:
changes["label"] = (arguments.get("label") or "").strip() or None
if "enabled" in arguments:
changes["enabled"] = _as_bool(arguments.get("enabled"))
if "notify" in arguments and arguments["notify"] is not None:
changes["notify"] = 1 if _as_bool(arguments.get("notify")) else 0
if "tz" in arguments and arguments["tz"] is not None:
changes["tz"] = (str(arguments.get("tz")) or "").strip() or None
if any(
key in arguments and arguments[key] is not None for key in SCHEDULE_KEYS
+26
View File
@@ -24,6 +24,24 @@ def memory_db() -> Any:
return dataset.connect("sqlite:///:memory:")
ACTIVE_STATUSES = ("pending", "running")
def pending_owner_ids(db: Any) -> list[str]:
if TABLE not in db.tables:
return []
table = db[TABLE]
if "owner_id" not in table.columns:
return []
rows = table.find(owner_kind="user", enabled=True, deleted_at=None)
owners = {
row["owner_id"]
for row in rows
if row.get("owner_id") and row.get("status") in ACTIVE_STATUSES
}
return sorted(owners)
class TaskStore:
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
self._db = db
@@ -39,6 +57,10 @@ class TaskStore:
table.create_column_by_example("deleted_at", "")
if not table.has_column("deleted_by"):
table.create_column_by_example("deleted_by", "")
if not table.has_column("notify"):
table.create_column_by_example("notify", 0)
if not table.has_column("tz"):
table.create_column_by_example("tz", "")
for columns in INDEXED_COLUMNS:
table.create_index(columns)
@@ -109,6 +131,10 @@ class TaskStore:
logger.info("Recovered %d task(s) stuck in running", len(stuck))
return len(stuck)
def has_pending(self) -> bool:
rows = self._table.find(enabled=True, deleted_at=None, **self._scope)
return any(row.get("status") in ACTIVE_STATUSES for row in rows)
def due(self, now_iso: str) -> list[dict[str, Any]]:
rows = self._table.find(
enabled=True, status="pending", deleted_at=None, **self._scope
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from .controller import TelegramSendController
__all__ = ["TelegramSendController"]
@@ -0,0 +1,54 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
from typing import Any
from devplacepy.services.devii.errors import ToolInputError
class TelegramSendController:
def __init__(self, owner_kind: str, owner_id: str) -> None:
self._owner_kind = owner_kind
self._owner_id = owner_id
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "telegram_send":
return await self._send(arguments)
raise ToolInputError(f"Unknown telegram tool: {name}")
async def _send(self, arguments: dict[str, Any]) -> str:
if self._owner_kind != "user":
raise ToolInputError(
"Telegram delivery is only available for signed-in users."
)
text = str(arguments.get("text", "")).strip()
if not text:
raise ToolInputError("'text' is required and cannot be empty.")
from devplacepy.services.telegram import store
link = store.link_for_user(self._owner_id)
if not link:
raise ToolInputError(
"Your Telegram account is not paired. Ask the user to request a pairing "
"code from their profile and connect Telegram first."
)
from devplacepy.services.manager import service_manager
service = service_manager.get_service("telegram")
if (
service is None
or not service.is_enabled()
or not service_manager.owns_lock()
or not service.worker_alive()
):
raise ToolInputError("The Telegram bot service is not running right now.")
delivered = await service.send_markdown(int(link["chat_id"]), text)
return json.dumps(
{
"status": "success" if delivered else "failed",
"delivered": delivered,
},
ensure_ascii=False,
)