Merge branch 'main' of github.com:pewdiepie-archdaemon/odysseus

# Conflicts:
#	static/js/cookbookRunning.js
This commit is contained in:
pewdiepie-archdaemon
2026-06-05 11:23:15 +09:00
33 changed files with 1105 additions and 151 deletions
+83 -36
View File
@@ -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)
+4 -21
View File
@@ -637,28 +637,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
+5 -2
View File
@@ -38,13 +38,16 @@ class TaskDeferred(BaseException):
async def action_tidy_sessions(owner: str, **kwargs) -> Tuple[str, bool]:
"""Delete empty/throwaway sessions for the owner. Pure heuristic —
"""Delete empty sessions for the owner. Pure heuristic —
the LLM folder-sort phase is skipped (user opted to keep this task
LLM-free; sorting can be triggered manually via the Chats UI)."""
try:
import asyncio
from src.session_actions import run_auto_sort
result = await asyncio.wait_for(run_auto_sort(owner, skip_llm=True), timeout=60)
result = await asyncio.wait_for(
run_auto_sort(owner, skip_llm=True, delete_throwaway=False),
timeout=60,
)
return result, True
except asyncio.TimeoutError:
logger.error("tidy_sessions action timed out")
+9
View File
@@ -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,
+12 -4
View File
@@ -129,7 +129,10 @@ def _get_http_client() -> httpx.AsyncClient:
"""Return process-wide AsyncClient. Per-request timeout is passed at call time."""
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(limits=_http_limits, http2=False)
from src.tls_overrides import llm_verify
_http_client = httpx.AsyncClient(
limits=_http_limits, http2=False, verify=llm_verify(),
)
return _http_client
def _get_cached_response(cache_key: str) -> Optional[str]:
@@ -1395,7 +1398,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
j = json.loads(data)
# Usage chunk (from stream_options)
_choices = j.get("choices") or []
_delta0 = _choices[0].get("delta") if _choices else None
_delta0 = _choices[0].get("delta") if (_choices and _choices[0] is not None) else None
# Capture usage whenever the chunk carries it and
# the delta has no actual output. Some gateways /
# local servers attach usage to the FINAL delta,
@@ -1409,7 +1412,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
or _delta0.get("tool_calls")
)
if "usage" in j and not _delta_has_output:
u = j["usage"]
u = j["usage"] or {}
_usage_data = {"input_tokens": u.get("prompt_tokens", 0), "output_tokens": u.get("completion_tokens", 0)}
# llama.cpp puts a `timings` block alongside `usage` with the
# TRUE generation speed (predicted_per_second) — pure decode,
@@ -1424,7 +1427,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
_usage_data["prefill_tps"] = round(_tm["prompt_per_second"], 2)
yield f'data: {json.dumps({"type": "usage", "data": _usage_data})}\n\n'
elif "choices" in j:
delta = j["choices"][0].get("delta") or {}
_c0 = (j["choices"] or [None])[0]
if _c0 is None:
continue
delta = _c0.get("delta") or {}
if isinstance(delta, dict):
# Text content
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Accept either.
@@ -1443,6 +1449,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
yield f'data: {json.dumps({"delta": content})}\n\n'
# Native tool calls — accumulate across chunks
for tc in delta.get("tool_calls") or []:
if tc is None:
continue
func = tc.get("function") or {}
raw_idx = tc.get("index")
if raw_idx is None:
+1
View File
@@ -7,6 +7,7 @@ parallel copy; it now re-exports so the two cannot drift out of sync again.
from services.search.ranking import ( # noqa: F401
_AGE_FORMATS,
_SPORTS_HINT_RE,
_utcnow_naive,
rank_search_results,
recency_score,
+9 -3
View File
@@ -8,7 +8,7 @@ and the task scheduler / builtin actions system.
import json
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
@@ -22,9 +22,10 @@ _THROWAWAY_NAMES = {
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
"""Run session cleanup + (optional) AI folder sort for the given owner.
Args:
@@ -32,6 +33,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
skip Phase 2 (AI folder assignment). Used by the built-in daily
background sweep so it never burns LLM tokens.
delete_throwaway: when False, only empty/incognito sessions are deleted.
Returns a human-readable summary of what was done.
"""
@@ -53,6 +55,8 @@ async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
for row in rows:
if getattr(row, 'is_important', False):
continue
created_at = row.created_at or row.updated_at or datetime.utcnow()
is_fresh = (datetime.utcnow() - created_at) < _FRESH_EMPTY_SESSION_GRACE
if (row.name or "").strip() == "Incognito":
deleted_throwaway += 1
db.delete(row)
@@ -64,9 +68,11 @@ async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
should_delete = False
if msg_count == 0:
if is_fresh:
continue
should_delete = True
deleted_empty += 1
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
+12 -4
View File
@@ -979,10 +979,10 @@ class TaskScheduler:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
return True
task_type = task.task_type or "llm"
task_type = getattr(task, "task_type", "") or "llm"
if task_type != "action":
return True
return (task.action or "") in self._MODEL_BACKED_ACTIONS
return (getattr(task, "action", "") or "") in self._MODEL_BACKED_ACTIONS
finally:
db.close()
@@ -992,7 +992,7 @@ class TaskScheduler:
if "check-in" in (task.name or "").lower():
return
# Built-in housekeeping noise stays out of the chat.
if (task.action or "") in self._SILENT_ACTIONS:
if (getattr(task, "action", "") or "") in self._SILENT_ACTIONS:
return
from src.assistant_log import log_to_assistant
log_to_assistant(
@@ -1408,6 +1408,12 @@ class TaskScheduler:
from core.database import Session as DbSession, ChatMessage, CrewMember
output = task.output_target or "session"
if (
output == "session"
and (getattr(task, "task_type", "") or "") == "action"
and (getattr(task, "action", "") or "") in self._SILENT_ACTIONS
):
return
if output.startswith("mcp__"):
await self._deliver_via_mcp(output, task, result)
return
@@ -2069,6 +2075,8 @@ class TaskScheduler:
# Built-in housekeeping/action jobs should not create browser
# task notifications; user AI/research tasks still can.
task.notifications_enabled = False
if (task.output_target or "session") == "session":
task.output_target = defs.get("output_target", "none")
seeded = []
for action, defs in HOUSEKEEPING_DEFAULTS.items():
if action in existing_actions:
@@ -2099,7 +2107,7 @@ class TaskScheduler:
# AI/email/calendar tasks opt into a paused starting state
# via ship_paused so users can enable them deliberately.
status="paused" if ships_paused else "active",
output_target="session",
output_target=defs.get("output_target", "none"),
notifications_enabled=False,
)
db.add(task)
+91
View File
@@ -0,0 +1,91 @@
"""Extended TLS trust store for private-CA LLM providers.
Some upstream LLM providers serve their API over TLS certificates that are
signed by a private root CA which is not part of the standard system bundle:
- GigaChat (Sber) uses the Russian Trusted Root CA, not bundled with
OpenSSL / certifi / system trust on most non-Russian installs. The
chain looks self-signed to Python and the endpoint is marked offline
with `CERTIFICATE_VERIFY_FAILED: self-signed certificate in
certificate chain` (see issue #722).
- On-premise enterprise LLM gateways often present a corporate CA that
has not been imported into the runtime's trust store.
Operators point `LLM_CA_BUNDLE` at a PEM file containing the extra CA
cert(s). The default system / certifi trust store is loaded first, then
the operator's PEM is layered on top, so verification still happens —
the trust set just gets larger. We deliberately do not provide a
"verify=off" knob: weakening verification globally (or per-host) would
expose those endpoints to MITM, and the operator-supplied bundle is the
correct fix for legitimate private-CA providers.
Example (GigaChat):
# Sber publishes the chain at
# https://www.gosuslugi.ru/crt/rootca_ssl_rsa2022.cer
# Convert to PEM and point the env var at it.
LLM_CA_BUNDLE=/etc/odysseus/ca/russian-trusted-root.pem
Scope:
`llm_verify()` is intentionally consumed by only two call sites the
shared async client in `src/llm_core.py` and the endpoint probes in
`routes/model_routes.py`. Both reach LLM provider URLs. The override
is NOT threaded into web_fetch, search providers, gallery downloads,
embeddings, webhook delivery, or anything else that hits arbitrary
URLs, and it does NOT affect the app's own browser-facing TLS. That
boundary is pinned by `tests/test_tls_overrides_scope.py` extending
it requires updating the allowlist there with a written justification.
"""
import logging
import os
import ssl
from typing import Optional
logger = logging.getLogger(__name__)
_extra_bundle_path: Optional[str] = (os.environ.get("LLM_CA_BUNDLE") or "").strip() or None
def _build_ssl_context() -> Optional[ssl.SSLContext]:
"""Build an SSLContext that uses the default trust store and ALSO trusts
the operator-supplied PEM bundle. Returns None when no extra bundle is
configured, so callers fall through to httpx's default verify=True."""
if not _extra_bundle_path:
return None
if not os.path.isfile(_extra_bundle_path):
logger.warning(
"LLM_CA_BUNDLE points at %r but the file does not exist; "
"falling back to the default trust store.",
_extra_bundle_path,
)
return None
ctx = ssl.create_default_context()
try:
ctx.load_verify_locations(cafile=_extra_bundle_path)
except (ssl.SSLError, OSError) as e:
logger.warning(
"LLM_CA_BUNDLE=%r failed to load (%s); falling back to the "
"default trust store.",
_extra_bundle_path, e,
)
return None
logger.info(
"Loaded extra CA bundle %r on top of the default trust store.",
_extra_bundle_path,
)
return ctx
# Resolved once at import time. The httpx clients in src/llm_core.py are
# long-lived (process-wide), so editing LLM_CA_BUNDLE requires a restart —
# matching the existing semantics of LLM_HOST, SEARXNG_INSTANCE, etc.
_SHARED_SSL_CONTEXT: Optional[ssl.SSLContext] = _build_ssl_context()
def llm_verify():
"""Return the value to pass as `verify=` on httpx.get / httpx.Client /
httpx.AsyncClient. Returns the extended-trust SSLContext when
LLM_CA_BUNDLE is set and loaded; otherwise True (httpx default system
/ certifi bundle, verification fully on)."""
return _SHARED_SSL_CONTEXT if _SHARED_SSL_CONTEXT is not None else True
+1 -1
View File
@@ -110,7 +110,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 <name>'; 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. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.",
"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'.",
+1 -1
View File
@@ -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": {
+138
View File
@@ -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"
)
+35 -2
View File
@@ -25,9 +25,27 @@ from src.research_utils import strip_thinking
from urllib.parse import urlparse
import markdown
import nh3
logger = logging.getLogger(__name__)
# Tags/attributes permitted in rendered research-report HTML. Starts from nh3's
# safe defaults (which drop <script>, inline event handlers, and javascript:
# URLs) and adds back only the formatting the report itself emits: the
# collapsible raw-findings block (<details>/<summary>), heading anchors for the
# table of contents (id), codehilite classes, table alignment, and the
# target/rel that _md_to_html puts on external links.
_REPORT_ALLOWED_TAGS = set(nh3.ALLOWED_TAGS) | {"details", "summary"}
_REPORT_ALLOWED_ATTRS = {k: set(v) for k, v in nh3.ALLOWED_ATTRIBUTES.items()}
for _h in ("h1", "h2", "h3", "h4", "h5", "h6"):
_REPORT_ALLOWED_ATTRS.setdefault(_h, set()).add("id")
for _t in ("span", "code", "pre", "div", "table", "td", "th"):
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("class")
for _t in ("td", "th"):
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -48,7 +66,14 @@ def _autolink_urls(md_text: str) -> str:
def _md_to_html(md_text: str) -> str:
"""Convert markdown to HTML with common extensions."""
"""Convert markdown to HTML with common extensions.
Research-report markdown is assembled from LLM output over crawled web
pages (untrusted content), and report pages are served under a relaxed
`script-src 'unsafe-inline'` CSP. python-markdown passes raw HTML through
verbatim, so the rendered output is allowlist-sanitized to strip any
<script>/inline-event-handler/javascript: markup before it reaches the page.
"""
md_text = _autolink_urls(md_text)
result = markdown.markdown(
md_text,
@@ -64,6 +89,14 @@ def _md_to_html(md_text: str) -> str:
r'<a target="_blank" rel="noopener noreferrer" href="\1',
result,
)
# Sanitize: report content is untrusted and the report CSP allows inline
# scripts, so strip active content while keeping the formatting above.
result = nh3.clean(
result,
tags=_REPORT_ALLOWED_TAGS,
attributes=_REPORT_ALLOWED_ATTRS,
link_rel=None,
)
return result
@@ -1864,7 +1897,7 @@ def generate_visual_report(
restore_btn_html=restore_btn_html,
timestamp=timestamp,
category_css=_category_css(category),
body_class=f"category-{category}" if category else "",
body_class=f"category-{html.escape(str(category))}" if category else "",
session_id_js=json_dumps_str(session_id or ""),
spare_images_js=_json_for_script(spare_images),
)