diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 4c79ce8..788a6ea 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -161,26 +161,18 @@ def _ensure_default_calendar(db, owner: str = None) -> CalendarCal: return cal -# Per-request user UTC offset (in minutes east of UTC). chat_routes sets this -# from the `X-Tz-Offset` header so naive natural-language times the LLM -# emits ("today at 9pm") are parsed in the USER's timezone, not the server's -# clock. None = unknown, fall back to legacy server-local behavior. -from contextvars import ContextVar -_USER_TZ_OFFSET_MIN: ContextVar = ContextVar("user_tz_offset_min", default=None) - - -def set_user_tz_offset(offset_min): - """Set the current user's UTC offset for this async context.""" - try: - v = int(offset_min) - except (TypeError, ValueError): - return - _USER_TZ_OFFSET_MIN.set(v) - - -def get_user_tz_offset(): - """Read the current user's UTC offset (minutes east of UTC), or None.""" - return _USER_TZ_OFFSET_MIN.get() +# Per-request user time context. chat_routes sets this from browser timezone +# headers so natural-language times the LLM emits ("today at 9pm") are parsed +# in the user's timezone, not the server's clock. None = unknown, fall back to +# legacy server-local behavior. +from src.user_time import ( + get_user_tz_name, + get_user_tz_offset, + now_user_local, + set_user_tz_name, + set_user_tz_offset, + user_timezone, +) def parse_due_for_user(s: str) -> str: @@ -199,6 +191,7 @@ def parse_due_for_user(s: str) -> str: """ from datetime import timezone as _tz, timedelta as _td offset = get_user_tz_offset() + tz_name = get_user_tz_name() s = (s or "").strip() if not s: return s @@ -212,11 +205,11 @@ def parse_due_for_user(s: str) -> str: except ValueError: parsed = None - if offset is None: + if offset is None and not tz_name: # No user tz known — preserve legacy behavior (naive server-local). return _parse_dt(s).isoformat() - user_tz = _tz(_td(minutes=offset)) + user_tz = user_timezone() # Naive ISO → tag with user tz. if parsed is not None and parsed.tzinfo is None: @@ -224,7 +217,7 @@ def parse_due_for_user(s: str) -> str: # Natural language — evaluate against user's "now". server_now_utc = datetime.now(_tz.utc) - user_now = server_now_utc.astimezone(user_tz) + user_now = now_user_local(server_now_utc) # Patch datetime.now() inside _parse_dt by leveraging the user's clock: # we re-implement the small natural-language phrases here against user_now # so the result is naturally in the user's tz. @@ -232,6 +225,7 @@ def parse_due_for_user(s: str) -> str: lower = s.lower().strip() def _parse_time(t): + t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower() @@ -341,6 +335,7 @@ def _parse_dt(s: str) -> datetime: def _parse_time(t: str): """Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None.""" + t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None @@ -1210,7 +1205,20 @@ def setup_calendar_routes() -> APIRouter: text = (body.get("text") or "").strip() if not text: raise HTTPException(400, "text is required") + from src.user_time import ( + clear_user_time_context, + current_datetime_prompt, + now_user_local, + set_user_tz_name, + set_user_tz_offset, + ) + + clear_user_time_context() tz_hint = (body.get("tz") or "").strip() + if body.get("tz_offset") is not None: + set_user_tz_offset(body.get("tz_offset")) + if tz_hint: + set_user_tz_name(tz_hint) url, model, headers = resolve_endpoint("utility") if not url: @@ -1218,15 +1226,15 @@ def setup_calendar_routes() -> APIRouter: if not url or not model: return {"ok": False, "error": "No LLM endpoint configured"} - now = datetime.now() + now = now_user_local() now_iso = now.strftime("%Y-%m-%dT%H:%M:%S") # The model gets only the schema it needs to fill out; we re-validate # everything client-side too. system_prompt = ( - "You are a calendar event parser. Read the user's one-line " + current_datetime_prompt() + + "You are a calendar event parser. Read the user's one-line " "description and emit STRICT JSON describing the event. " - f"Today is {now.strftime('%A, %Y-%m-%d')} ({now_iso}). " - + (f"User timezone: {tz_hint}. " if tz_hint else "") + f"The current user-local timestamp is {now_iso}. " + "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", " "\"in 30 minutes\") against today. Default duration is 60 minutes " "when no end time is given. If the text mentions a date with no " diff --git a/routes/chat_routes.py b/routes/chat_routes.py index f54c265..8dd17a5 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -37,7 +37,7 @@ from routes.chat_helpers import ( clean_thinking_for_save, _enforce_chat_privileges, ) -from src.action_intents import message_needs_tools as _message_needs_tools +from src.action_intents import classify_tool_intent as _classify_tool_intent logger = logging.getLogger(__name__) @@ -229,6 +229,26 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None db.close() +def _set_user_time_from_request(request: Request) -> None: + """Copy browser timezone headers into the per-request context. + + This is intentionally ephemeral: it is used only while building prompts + and running tools for this request. It is not persisted or logged. + """ + try: + tz_offset = request.headers.get("x-tz-offset") + tz_name = request.headers.get("x-tz-name") + from src.user_time import clear_user_time_context, set_user_tz_name, set_user_tz_offset + + clear_user_time_context() + if tz_offset is not None: + set_user_tz_offset(tz_offset) + if tz_name: + set_user_tz_name(tz_name) + except Exception: + pass + + def setup_chat_routes( session_manager, chat_handler, @@ -247,6 +267,8 @@ def setup_chat_routes( # ------------------------------------------------------------------ # @router.post("/api/chat", response_model=Dict[str, str]) async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]: + _set_user_time_from_request(request) + message = chat_request.message session = chat_request.session att_ids = chat_request.attachments or [] @@ -355,16 +377,7 @@ def setup_chat_routes( except Exception as e: raise HTTPException(400, f"Request parsing error: {e}") - # Stash the user's UTC offset (in minutes east of UTC) from the - # frontend so tools like manage_notes interpret natural-language - # times in the USER's tz, not the server's. See calendar_routes. - try: - _tz_hdr = request.headers.get("x-tz-offset") - if _tz_hdr is not None: - from routes.calendar_routes import set_user_tz_offset - set_user_tz_offset(_tz_hdr) - except Exception: - pass + _set_user_time_from_request(request) form_data = await request.form() message = form_data.get("message") @@ -393,10 +406,15 @@ def setup_chat_routes( # its way through a plain chat request (and fail, especially with the # shell disabled). auto_escalated = False - if chat_mode == "chat" and isinstance(message, str) and _message_needs_tools(message): + _tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None + if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools: chat_mode = "agent" auto_escalated = True - logger.info("chat→agent auto-escalation: message matched tool-intent pattern") + logger.info( + "chat→agent auto-escalation: category=%s reason=%s", + _tool_intent.category, + _tool_intent.reason, + ) active_doc_id = form_data.get("active_doc_id", "").strip() logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}") diff --git a/src/action_intents.py b/src/action_intents.py index 7054801..84734ab 100644 --- a/src/action_intents.py +++ b/src/action_intents.py @@ -8,74 +8,121 @@ user asks how a feature works. from __future__ import annotations import re +from dataclasses import dataclass from typing import Iterable, Pattern -_ACTION_QUESTION = r"\b(?:can|could|would|will)\s+you\s+" -_PLEASE = r"^\s*(?:please\s+)?" +@dataclass(frozen=True) +class ToolIntent: + """A cheap, deterministic chat-to-agent routing decision.""" -_CALENDAR_ACTION = r"(?:add|create|schedule|book|put|set\s+up|make)" + needs_tools: bool + category: str = "" + reason: str = "" + + +_ACTION_QUESTION = r"\b(?:can|could|would|will)\s+you\s+" +_ACTION_FOLLOWUP = ( + r"\b(?:you\s+should\s+be\s+able\s+to|" + r"(?:can|could|would|will|should)\s+you|" + r"you\s+(?:can|could|would|will|should|need\s+to|have\s+to))\s+" +) +_PLEASE = r"^\s*(?:(?:please|ok(?:ay)?|alright|right|sure|cool|great|thanks)[\s,.!-]+)*" + +_CALENDAR_ACTION = ( + r"(?:add|adding|create|creating|recreate|recreating|schedule|scheduling|" + r"reschedule|rescheduling|book|booking|put|set\s+up|make|making|" + r"delete|deleting|remove|removing|cancel|cancelling|canceling)" +) _CALENDAR_THING = r"(?:calendar|calendar\s+(?:entry|item)|event|meeting|appointment|entry|call)" +_EXPLANATORY_PREFIX = re.compile( + r"^\s*(?:how\s+(?:do|can)\s+i|can\s+you\s+explain|what\s+about|tell\s+me\s+how|show\s+me\s+how)\b", + re.I, +) _PANEL = ( r"(?:calendar|notes?|inbox|email|mail|documents?|docs|library|gallery|" r"settings|cookbook|sessions?|chats?|skills|memories|memory|brain)" ) -_TOOL_INTENT_PATTERNS: tuple[Pattern[str], ...] = tuple( - re.compile(pattern, re.I) - for pattern in ( +_ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple( + (category, reason, re.compile(pattern, re.I)) + for category, reason, pattern in ( # Calendar/event creation. Covers "Can you add an entry to my - # calendar?" and imperatives like "add lunch to my calendar". - rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b", - rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b", - rf"{_PLEASE}{_CALENDAR_ACTION}\s+(?:a\s+|an\s+)?(?:calendar\s+)?(?:event|meeting|appointment|entry|item|call)\b", - r"\bput\s+.+\bon\s+(?:my\s+)?calendar\b", + # calendar?", imperatives like "add lunch to my calendar", and + # follow-ups such as "you should be able to create that event now". + ("calendar", "assistant calendar action request", rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"), + ("calendar", "calendar follow-up action request", rf"{_ACTION_FOLLOWUP}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"), + ("calendar", "calendar imperative action request", rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"), + ("calendar", "calendar target action request", rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b"), + ("calendar", "calendar item action request", rf"{_PLEASE}{_CALENDAR_ACTION}\s+(?:it\s+)?(?:a\s+|an\s+)?(?:calendar\s+)?(?:event|meeting|appointment|entry|item|call)\b"), + ("calendar", "calendar target action request", rf"\b{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b"), + ("calendar", "put item on calendar request", r"\bput\s+.+\bon\s+(?:my\s+)?calendar\b"), # Notes, todos, checklists, and reminders. - r"\bremind\s+me\b", - rf"{_ACTION_QUESTION}(?:add|create|make|take|jot|write\s+down|set)\b.{{0,120}}\b(?:note|todo|task|checklist|reminder)\b", - rf"{_PLEASE}(?:add|create|make)\s+(?:a\s+|an\s+)?(?:todo|task|reminder|note|checklist)\b", - rf"{_PLEASE}(?:take|jot|write\s+down)\s+(?:a\s+|an\s+)?note\b", - rf"{_PLEASE}(?:add|jot|write\s+down)\b.{{0,120}}\b(?:to|in|into)\s+(?:my\s+|the\s+)?(?:todo(?:\s+list)?|task\s+list|notes?|checklist)\b", - rf"{_PLEASE}set\s+(?:a\s+)?reminder\b", - rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b", + ("notes", "reminder request", r"\bremind\s+me\b"), + ("notes", "assistant note/todo action request", rf"{_ACTION_QUESTION}(?:add|create|make|take|jot|write\s+down|set)\b.{{0,120}}\b(?:note|todo|task|checklist|reminder)\b"), + ("notes", "note/todo imperative request", rf"{_PLEASE}(?:add|create|make)\s+(?:a\s+|an\s+)?(?:todo|task|reminder|note|checklist)\b"), + ("notes", "take note request", rf"{_PLEASE}(?:take|jot|write\s+down)\s+(?:a\s+|an\s+)?note\b"), + ("notes", "add item to notes/todo request", rf"{_PLEASE}(?:add|jot|write\s+down)\b.{{0,120}}\b(?:to|in|into)\s+(?:my\s+|the\s+)?(?:todo(?:\s+list)?|task\s+list|notes?|checklist)\b"), + ("notes", "set reminder request", rf"{_PLEASE}set\s+(?:a\s+)?reminder\b"), + ("notes", "assistant reminder request", rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b"), # Email actions. - rf"{_ACTION_QUESTION}(?:send|write|reply|email|message|archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox|unread|read)\b", - rf"{_PLEASE}(?:send|write|reply)\b.{{0,120}}\b(?:emails?|mail|messages?)\b", - rf"{_PLEASE}(?:archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox)\b", - r"\b(?:send|write|reply)\s+(?:an?\s+)?(?:email|message|mail)\b", - r"\bemail\s+\w+\b", - r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b", - r"\bunread\s+(?:email|mail)s?\b", + ("email", "assistant email action request", rf"{_ACTION_QUESTION}(?:send|write|reply|email|message|archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox|unread|read)\b"), + ("email", "send/write/reply email request", rf"{_PLEASE}(?:send|write|reply)\b.{{0,120}}\b(?:emails?|mail|messages?)\b"), + ("email", "archive/delete/mark email request", rf"{_PLEASE}(?:archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox)\b"), + ("email", "email composition request", r"\b(?:send|write|reply)\s+(?:an?\s+)?(?:email|message|mail)\b"), + ("email", "email contact request", r"\bemail\s+\w+\b"), + ("email", "check inbox request", r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b"), + ("email", "unread email request", r"\bunread\s+(?:email|mail)s?\b"), # UI/control-plane actions that should open panels or flip toggles. - rf"{_PLEASE}(?:open|show|bring\s+up)\s+(?:me\s+)?(?:my\s+|the\s+)?{_PANEL}\b", - r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b", + ("ui", "open/show panel request", rf"{_PLEASE}(?:open|show|bring\s+up)\s+(?:me\s+)?(?:my\s+|the\s+)?{_PANEL}\b"), + ("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"), # Deep research jobs, not quick conceptual mentions of research. - rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+", - rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+", + ("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"), + ("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"), # Shell / remote-host intent. - r"\bssh\s+(?:in)?to\b", - r"\bssh\s+\w+", - r"\b(run|execute)\s+.{1,40}\bon\s+\w+", - r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b", + ("shell", "ssh request", r"\bssh\s+(?:in)?to\b"), + ("shell", "ssh target request", r"\bssh\s+\w+"), + ("shell", "remote command request", r"\b(run|execute)\s+.{1,40}\bon\s+\w+"), + ("shell", "assistant command execution request", r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b"), # Shell verbs only count in imperative position (start of message, # optionally after "please") or as a "can you ..." request. A bare # word match promoted informational questions ("What does the grep # command do?") and incidental uses ("My cat ate my homework"). - rf"{_PLEASE}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+", - rf"{_ACTION_QUESTION}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+", - r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b", + ("shell", "imperative shell command request", rf"{_PLEASE}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+"), + ("shell", "assistant shell command request", rf"{_ACTION_QUESTION}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+"), + ("shell", "system/file check request", r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b"), ) ) +_TOOL_INTENT_PATTERNS: tuple[Pattern[str], ...] = tuple( + pattern for _, _, pattern in _ROUTING_PATTERNS +) + + +def classify_tool_intent(text: str) -> ToolIntent: + """Classify whether a chat message should be promoted to agent mode.""" + if not text: + return ToolIntent(False, reason="empty message") + if _EXPLANATORY_PREFIX.search(text): + return ToolIntent(False, reason="explanatory feature question") + for category, reason, pattern in _ROUTING_PATTERNS: + if pattern.search(text): + return ToolIntent(True, category=category, reason=reason) + return ToolIntent(False, reason="no tool-action pattern matched") + def message_needs_tools(text: str, patterns: Iterable[Pattern[str]] = _TOOL_INTENT_PATTERNS) -> bool: """Return True when a plain chat message should be promoted to agent mode.""" if not text: return False + if _EXPLANATORY_PREFIX.search(text): + return False + if patterns is _TOOL_INTENT_PATTERNS: + return classify_tool_intent(text).needs_tools return any(pattern.search(text) for pattern in patterns) diff --git a/src/agent_loop.py b/src/agent_loop.py index c0a7cc6..653baa9 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -636,28 +636,11 @@ def _build_system_prompt( set_active_model(model) - # Current date/time — every request. Models default to their - # training-cutoff date when "today" is asked otherwise (was - # rendering April 2026 dates as "today" when the actual date is - # May 19, 2026). System TZ-local so calendar/email date math - # matches what the user sees. + # Current date/time for every agent request. This is user-local when the + # browser provided timezone headers, with a server-local fallback. try: - from datetime import datetime as _dt, timezone as _tz - _now = _dt.now().astimezone() - _utc = _dt.now(_tz.utc) - _off = _now.strftime('%z') # e.g. +0900 - _off_fmt = (f"{_off[:3]}:{_off[3:]}" if _off else "+00:00") - agent_prompt = ( - f"## Current date and time\n" - f"Today is {_now.strftime('%A, %B %-d, %Y')} ({_now.strftime('%Y-%m-%d')}). " - f"Local time is {_now.strftime('%-I:%M %p')} ({_now.strftime('%Z')}, UTC{_off_fmt}); " - f"current UTC time is {_utc.strftime('%H:%M')}. " - f"Use this for any 'today'/'tomorrow'/'this week' reasoning — do NOT " - f"infer the date from training data or from event timestamps.\n" - f"When scheduling a task (manage_tasks), scheduled_time is in UTC: " - f"subtract the offset above from the user's local time " - f"(local {_now.strftime('%H:%M')} = {_utc.strftime('%H:%M')} UTC right now).\n\n" - ) + agent_prompt + from src.user_time import current_datetime_prompt + agent_prompt = current_datetime_prompt() + agent_prompt except Exception: pass diff --git a/src/chat_processor.py b/src/chat_processor.py index 47ff76c..02062ae 100644 --- a/src/chat_processor.py +++ b/src/chat_processor.py @@ -185,6 +185,15 @@ class ChatProcessor: "role": "system", "content": preset_system_prompt }) + if not agent_mode: + try: + from src.user_time import current_datetime_prompt + preface.append({ + "role": "system", + "content": current_datetime_prompt(), + }) + except Exception: + logger.debug("Failed to add current date/time context", exc_info=True) preface.append({ "role": "system", "content": UNTRUSTED_CONTEXT_POLICY, diff --git a/src/tool_index.py b/src/tool_index.py index 506e55d..3c5150e 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -102,7 +102,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.", "manage_contact": "Create, update, delete, or list CardDAV contacts. Use to save a new contact, change an existing one's email/phone, or remove one. Action=list returns uids needed for update/delete. Use when the user says 'save this contact', 'add [name] to contacts', 'update [name]'s email', 'delete [name] from contacts'. Do not use for user identity facts like 'my name is '; those are memory.", "manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.", - "manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Use ISO datetimes; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.", + "manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.", "download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.", "serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions.", "list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index f55fb82..b862301 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -422,7 +422,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "manage_calendar", - "description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Use ISO 8601 datetimes; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.", + "description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.", "parameters": { "type": "object", "properties": { diff --git a/src/user_time.py b/src/user_time.py new file mode 100644 index 0000000..44519c0 --- /dev/null +++ b/src/user_time.py @@ -0,0 +1,138 @@ +"""Per-request user-local time helpers. + +Chat routes set this context from browser headers. Prompt builders and tools +can then resolve relative dates against the user's clock instead of the server. +""" + +from __future__ import annotations + +import re +from contextvars import ContextVar +from datetime import datetime, timedelta, timezone +from typing import Optional + + +_USER_TZ_OFFSET_MIN: ContextVar[Optional[int]] = ContextVar("user_tz_offset_min", default=None) +_USER_TZ_NAME: ContextVar[Optional[str]] = ContextVar("user_tz_name", default=None) + + +def set_user_tz_offset(offset_min) -> None: + """Set the current user's UTC offset in minutes east of UTC.""" + if offset_min in (None, ""): + _USER_TZ_OFFSET_MIN.set(None) + return + try: + value = int(offset_min) + except (TypeError, ValueError): + return + if -14 * 60 <= value <= 14 * 60: + _USER_TZ_OFFSET_MIN.set(value) + + +def get_user_tz_offset() -> Optional[int]: + """Return minutes east of UTC for the current user, if known.""" + return _USER_TZ_OFFSET_MIN.get() + + +def set_user_tz_name(name) -> None: + """Set a safe IANA timezone label for the current request context.""" + if not name: + _USER_TZ_NAME.set(None) + return + first_token = str(name).strip().split()[0] if str(name).strip() else "" + cleaned = re.sub(r"[^A-Za-z0-9_+\-./]", "", first_token)[:80] + _USER_TZ_NAME.set(cleaned or None) + + +def get_user_tz_name() -> Optional[str]: + """Return the current user's browser timezone name, if provided.""" + return _USER_TZ_NAME.get() + + +def clear_user_time_context() -> None: + """Clear user-local time context for tests and non-browser entry points.""" + _USER_TZ_OFFSET_MIN.set(None) + _USER_TZ_NAME.set(None) + + +def format_utc_offset(offset_min: Optional[int]) -> str: + """Format minutes east of UTC as +HH:MM or -HH:MM.""" + if offset_min is None: + offset_min = 0 + sign = "+" if offset_min >= 0 else "-" + total = abs(int(offset_min)) + hours, minutes = divmod(total, 60) + return f"{sign}{hours:02d}:{minutes:02d}" + + +def user_timezone() -> timezone: + """Return the best known user timezone as a fixed-offset tzinfo.""" + offset = get_user_tz_offset() + if offset is None: + name = get_user_tz_name() + if name: + try: + from zoneinfo import ZoneInfo + return ZoneInfo(name) + except Exception: + pass + return datetime.now().astimezone().tzinfo or timezone.utc + return timezone(timedelta(minutes=offset)) + + +def now_user_local(now_utc: Optional[datetime] = None) -> datetime: + """Return the current time in the user's timezone.""" + if now_utc is None: + now_utc = datetime.now(timezone.utc) + elif now_utc.tzinfo is None: + now_utc = now_utc.replace(tzinfo=timezone.utc) + return now_utc.astimezone(user_timezone()) + + +def _date_label(dt: datetime) -> str: + return f"{dt.strftime('%A')}, {dt.strftime('%B')} {dt.day}, {dt.year}" + + +def _clock_label(dt: datetime) -> str: + hour = dt.hour % 12 or 12 + return f"{hour}:{dt.minute:02d} {dt.strftime('%p')}" + + +def timezone_label(dt: Optional[datetime] = None) -> str: + """Return a concise display label such as Australia/Brisbane, UTC+10:00.""" + offset = get_user_tz_offset() + if offset is None: + if dt is None: + dt = datetime.now().astimezone() + offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60) + offset_label = f"UTC{format_utc_offset(offset)}" + name = get_user_tz_name() + return f"{name}, {offset_label}" if name else offset_label + + +def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str: + """Build reusable system prompt text for date/time reasoning.""" + if now_utc is None: + utc_now = datetime.now(timezone.utc) + elif now_utc.tzinfo is None: + utc_now = now_utc.replace(tzinfo=timezone.utc) + else: + utc_now = now_utc.astimezone(timezone.utc) + + local_now = now_user_local(utc_now) + tomorrow = local_now + timedelta(days=1) + return ( + "## Current date and time\n" + f"Today is {_date_label(local_now)} ({local_now.strftime('%Y-%m-%d')}). " + f"User local time is {_clock_label(local_now)} ({timezone_label(local_now)}); " + f"current UTC time is {utc_now.strftime('%H:%M')}.\n" + f"Tomorrow is {_date_label(tomorrow)} ({tomorrow.strftime('%Y-%m-%d')}) " + "in the user's local timezone.\n" + "Use this for any 'today', 'tomorrow', 'tonight', 'this week', or other " + "relative-date reasoning. Do not ask for an exact date just because the " + "user used a relative date.\n" + "When scheduling calendar events with manage_calendar, pass local ISO " + "datetimes resolved against this user-local date/time.\n" + "When scheduling a task with manage_tasks, scheduled_time is in UTC: " + "convert the user's stated local time using the UTC offset above.\n\n" + ) diff --git a/static/js/calendar.js b/static/js/calendar.js index 31a4423..ebd6bfc 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -1876,11 +1876,12 @@ function _wireAll(body) { } try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + const tzOffset = -new Date().getTimezoneOffset(); const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, tz }), + body: JSON.stringify({ text, tz, tz_offset: tzOffset }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { diff --git a/static/js/chat.js b/static/js/chat.js index f14c715..dd47188 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -530,6 +530,9 @@ import createResearchSynapse from './researchSynapse.js'; let _renderStream = () => {}; let _cancelThinkingTimer = () => {}; let _removeThinkingSpinner = () => {}; + let timeoutId = null; + let responseTimeoutCleared = false; + let clearResponseTimeout = () => {}; const clearProcessingProbe = () => { if (processingProbeTimer) { clearTimeout(processingProbeTimer); @@ -790,13 +793,26 @@ import createResearchSynapse from './researchSynapse.js'; // Timeout: 6 min for research and agent mode, 3 min otherwise const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; - const timeoutId = setTimeout(() => { + timeoutId = setTimeout(() => { if (!abortCtrl.signal.aborted) { timedOut = true; abortCtrl._reason = 'timeout'; + try { + if (streamSessionId) { + fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, { + method: 'POST', + credentials: 'same-origin', + }).catch(() => {}); + } + } catch (_) {} abortCtrl.abort(); } }, timeoutMs); + clearResponseTimeout = () => { + if (responseTimeoutCleared) return; + responseTimeoutCleared = true; + clearTimeout(timeoutId); + }; const box = el('chat-history'); holder = document.createElement('div'); @@ -922,16 +938,19 @@ import createResearchSynapse from './researchSynapse.js'; // the agent so natural-language times like "today at 9pm" are // interpreted in YOUR timezone, not the server's. const _tzOffsetMin = -new Date().getTimezoneOffset(); + const _tzName = (() => { + try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } + catch { return ''; } + })(); const res = await fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', body: fd, - headers: { 'X-Tz-Offset': String(_tzOffsetMin) }, + headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName }, signal: abortCtrl.signal }); - clearTimeout(timeoutId); - if (!res.ok) { + clearResponseTimeout(); if (res.status === 404) { // Session was deleted (e.g. by AI) — reload and go to welcome holder.remove(); @@ -1359,7 +1378,8 @@ import createResearchSynapse from './researchSynapse.js'; typewriterInto(roundHolder.querySelector('.body'), errMsg); break; } - if (json.delta || json.type === 'tool_start' || json.type === 'agent_step' || json.type === 'doc_stream_delta') { + if (json.delta || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') { + clearResponseTimeout(); clearProcessingProbe(); } if (json.delta) { @@ -2710,6 +2730,7 @@ import createResearchSynapse from './researchSynapse.js'; } } } finally { + clearResponseTimeout(); clearProcessingProbe(); // Streaming done — let screen readers announce the settled response. const _chatLogDone = document.getElementById('chat-history'); diff --git a/tests/test_action_intents.py b/tests/test_action_intents.py index 87f59fa..164ed4d 100644 --- a/tests/test_action_intents.py +++ b/tests/test_action_intents.py @@ -1,14 +1,26 @@ -from src.action_intents import message_needs_tools +from src.action_intents import classify_tool_intent, message_needs_tools def test_calendar_entry_request_promotes_to_agent(): assert message_needs_tools("Can you add an entry to my calendar?") + intent = classify_tool_intent("Can you add an entry to my calendar?") + assert intent.needs_tools + assert intent.category == "calendar" def test_calendar_imperative_variants_promote_to_agent(): assert message_needs_tools("add lunch with Sam to my calendar tomorrow at noon") assert message_needs_tools("schedule a call with Mina next Friday") assert message_needs_tools("put dentist appointment on my calendar") + assert message_needs_tools("Alright. Recreate that same appointment") + assert message_needs_tools("Okay delete that doctor appointment from the calendar") + assert message_needs_tools("have another go at adding a test entry to the calendar") + assert message_needs_tools( + "Okay so you should be able to create that calendar event for tomorrow at 1:30 p.m. right for me to go to the hardware store" + ) + assert message_needs_tools( + "make it an appointment at 12pm for me to visit the doctor it's tomorrow the 2nd of June 2026" + ) def test_note_todo_and_reminder_actions_promote_to_agent(): @@ -33,3 +45,12 @@ def test_explanatory_calendar_questions_stay_plain_chat(): assert not message_needs_tools("How do I add an entry to my calendar?") assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?") assert not message_needs_tools("Can you explain how calendar reminders work?") + intent = classify_tool_intent("How do I add an entry to my calendar?") + assert not intent.needs_tools + assert intent.reason == "explanatory feature question" + + +def test_router_reports_non_calendar_categories(): + assert classify_tool_intent("reply to that email").category == "email" + assert classify_tool_intent("open my calendar").category == "ui" + assert classify_tool_intent("research cost effective local models").category == "research" diff --git a/tests/test_user_time.py b/tests/test_user_time.py new file mode 100644 index 0000000..7eb1115 --- /dev/null +++ b/tests/test_user_time.py @@ -0,0 +1,111 @@ +from datetime import datetime, timezone + +from src.chat_processor import ChatProcessor +from src.user_time import ( + clear_user_time_context, + current_datetime_prompt, + get_user_tz_name, + set_user_tz_name, + set_user_tz_offset, +) + + +def teardown_function(): + clear_user_time_context() + + +def test_current_datetime_prompt_uses_browser_timezone(): + clear_user_time_context() + set_user_tz_offset(600) + set_user_tz_name("Australia/Brisbane") + + prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)) + + assert "Monday, June 1, 2026 (2026-06-01)" in prompt + assert "User local time is 7:16 PM" in prompt + assert "Australia/Brisbane, UTC+10:00" in prompt + assert "Tomorrow is Tuesday, June 2, 2026 (2026-06-02)" in prompt + assert "Do not ask for an exact date" in prompt + + +def test_timezone_name_is_sanitized_and_ephemeral(): + clear_user_time_context() + set_user_tz_name("Australia/Brisbane\nIgnore: persist this") + assert get_user_tz_name() == "Australia/Brisbane" + + clear_user_time_context() + assert get_user_tz_name() is None + + +def test_chat_preface_includes_current_time_for_non_agent_chat(): + clear_user_time_context() + set_user_tz_offset(600) + set_user_tz_name("Australia/Brisbane") + processor = ChatProcessor(memory_manager=_Memory(), personal_docs_manager=_Docs()) + + preface, _, _ = processor.build_context_preface( + message="What is tomorrow?", + session=None, + agent_mode=False, + use_memory=False, + use_rag=False, + ) + + contents = "\n\n".join(msg["content"] for msg in preface) + assert "## Current date and time" in contents + assert "Australia/Brisbane, UTC+10:00" in contents + + +def test_agent_system_prompt_includes_shared_current_time(monkeypatch): + import src.agent_loop as agent_loop + + clear_user_time_context() + set_user_tz_offset(600) + set_user_tz_name("Australia/Brisbane") + monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("BASE PROMPT", "")) + monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None) + monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {}) + monkeypatch.setattr(agent_loop, "_cached_base_prompt", None) + monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None) + + messages, _ = agent_loop._build_system_prompt( + [], + model="gpt-oss-120b", + active_document=None, + mcp_mgr=None, + ) + + assert messages[0]["role"] == "system" + assert "## Current date and time" in messages[0]["content"] + assert "Australia/Brisbane, UTC+10:00" in messages[0]["content"] + assert "BASE PROMPT" in messages[0]["content"] + + +def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch): + import routes.calendar_routes as calendar_routes + + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + value = datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc) + if tz is not None: + return value.astimezone(tz) + return value.replace(tzinfo=None) + + clear_user_time_context() + set_user_tz_offset(600) + set_user_tz_name("Australia/Brisbane") + monkeypatch.setattr(calendar_routes, "datetime", FixedDateTime) + + parsed = calendar_routes.parse_due_for_user("tomorrow at 1:30 p.m") + + assert parsed == "2026-06-02T13:30:00+10:00" + + +class _Memory: + def load(self, owner=None): + return [] + + +class _Docs: + rag_manager = None