diff --git a/.env.example b/.env.example
index e53d2f8..f282880 100644
--- a/.env.example
+++ b/.env.example
@@ -27,6 +27,16 @@ LLM_HOST=localhost
# Research service LLM endpoint
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
+# Extra CA bundle for LLM providers whose TLS chain isn't in the default
+# trust store. Layered ON TOP of the system / certifi bundle — verification
+# stays on for every host, the trust set just gets larger. Useful for:
+# - GigaChat / Sber (Russian Trusted Root CA): without this the endpoint
+# shows offline with CERTIFICATE_VERIFY_FAILED — self-signed certificate
+# in certificate chain.
+# - On-premise / corporate LLM gateways with an internal CA.
+# Point at a PEM file containing the missing root(s).
+# LLM_CA_BUNDLE=/etc/odysseus/ca/extra-roots.pem
+
# ============================================================
# Search & Web
# ============================================================
diff --git a/requirements.txt b/requirements.txt
index e4630d1..2c40729 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,6 +21,10 @@ youtube-transcript-api
# Markdown rendering for research reports (src/visual_report.py).
# Imported at module-top so it's a hard core dep, not optional.
markdown
+# HTML sanitizer for rendered research reports (src/visual_report.py). Report
+# content is untrusted (LLM output over crawled pages) and report pages run
+# under a relaxed CSP, so the rendered HTML is allowlist-sanitized.
+nh3
# Calendar .ics import/export (routes/calendar_routes.py).
icalendar
# Recurrence rule expansion for calendar events (routes/calendar_routes.py).
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..a3c6c16 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}")
@@ -507,7 +525,24 @@ def setup_chat_routes(
_doc_q = _doc_db.query(DBDocument).filter(DBDocument.id == active_doc_id)
active_doc = _owner_session_filter(_doc_q, ctx.user).first()
if active_doc:
- logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
+ doc_session = active_doc.session_id
+ doc_owner = getattr(active_doc, "owner", None)
+ if doc_owner and ctx.user and doc_owner != ctx.user:
+ logger.warning(
+ "[doc-inject] ignoring active_doc_id %s owned by another user",
+ active_doc_id,
+ )
+ active_doc = None
+ elif doc_session and doc_session != session:
+ logger.warning(
+ "[doc-inject] ignoring stale active_doc_id %s from session %s while in session %s",
+ active_doc_id,
+ doc_session,
+ session,
+ )
+ active_doc = None
+ else:
+ logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
else:
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
if not active_doc:
diff --git a/routes/model_routes.py b/routes/model_routes.py
index 0cf98d5..ac025ad 100644
--- a/routes/model_routes.py
+++ b/routes/model_routes.py
@@ -17,6 +17,7 @@ from fastapi.responses import StreamingResponse
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
from core.middleware import require_admin
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
+from src.tls_overrides import llm_verify
from src.settings import load_settings as _load_settings, save_settings as _save_settings
from src.endpoint_resolver import (
normalize_base as _normalize_base,
@@ -624,7 +625,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
if api_key:
headers["x-api-key"] = api_key
try:
- r = httpx.get(url, headers=headers, timeout=timeout)
+ r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
@@ -645,7 +646,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
url = build_models_url(base)
headers = build_headers(api_key, base)
try:
- r = httpx.get(url, headers=headers, timeout=timeout)
+ r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
r.raise_for_status()
data = r.json()
# OpenAI format: {"data": [{"id": "model-name"}]}
@@ -680,7 +681,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
parsed = urlparse(base)
if parsed.port == 11434 or "ollama" in (parsed.hostname or "").lower():
root = base[:-3].rstrip("/") if base.endswith("/v1") else base
- r = httpx.get(root + "/api/tags", timeout=timeout)
+ r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
r.raise_for_status()
data = r.json()
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
@@ -741,7 +742,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
break
for path in ("/api/version", "/api/tags"):
try:
- r = httpx.get(root + path, timeout=timeout)
+ r = httpx.get(root + path, timeout=timeout, verify=llm_verify())
result = _result_from_response(r)
if result["reachable"]:
return result
@@ -752,7 +753,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
pass
try:
- r = httpx.get(base, headers=headers, timeout=timeout)
+ r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify())
return _result_from_response(r)
except Exception as e:
last_error = str(e)[:120]
diff --git a/routes/session_routes.py b/routes/session_routes.py
index 1b38e4b..049635d 100644
--- a/routes/session_routes.py
+++ b/routes/session_routes.py
@@ -37,6 +37,26 @@ def _public_model(name: str, model: str) -> str:
return model
+def _content_to_text(content) -> str:
+ """Flatten a message's content to plain text for text-based exports.
+
+ History entries carry three shapes: a plain string, a multimodal list of
+ content blocks (vision/image attachments), or None (assistant turns that
+ persisted only native tool_calls). The txt/html/md exporters join and
+ string-munge this value, so a list crashed the export (TypeError on join,
+ AttributeError on .replace) and None rendered as the literal "None".
+ Coerce to the text blocks, returning "" for anything without text.
+ """
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ return "\n".join(
+ b.get("text", "") for b in content
+ if isinstance(b, dict) and b.get("text")
+ )
+ return ""
+
+
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
"""Verify the current user owns the session. Raises 404 if not.
@@ -74,7 +94,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["sessions"])
-
def _current_user_is_admin(request: Request, user: str | None) -> bool:
if not user:
return False
@@ -122,6 +141,17 @@ def _persist_session_headers(session_id: str, headers: dict | None) -> None:
db.close()
+_HIDDEN_SYSTEM_SESSION_NAMES = {
+ "[Task] Chat Sessions Tidy",
+ "[Task] Documents Tidy",
+ "[Task] Memory Tidy",
+ "[Task] Research Tidy",
+ "[Task] Email Mark Boundaries",
+ "[Task] Email Tags",
+ "[Task] Skills Audit",
+}
+
+
def _pick_endpoint_for_sort(owner=None):
"""Pick model endpoint for auto-sort LLM call — uses utility endpoint setting, falls back to default."""
from src.endpoint_resolver import resolve_endpoint
@@ -245,7 +275,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
"message_count": msg_count_map.get(s.id, 0)}
for s in user_sessions.values()
if not s.archived
- and (s.name or "").strip() not in ("Nobody", "Incognito")]
+ and (s.name or "").strip() not in ("Nobody", "Incognito")
+ and (s.name or "").strip() not in _HIDDEN_SYSTEM_SESSION_NAMES]
return sessions
@@ -708,7 +739,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
lines = []
for m in session.history:
lines.append(f"[{m.role.upper()}]")
- lines.append(m.content)
+ lines.append(_content_to_text(m.content))
lines.append("")
out_name = filename or f"conversation_{safe_name}_{timestamp}.txt"
return Response(
@@ -731,7 +762,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
]
for m in session.history:
cls = "user" if m.role == "user" else "ai"
- content = m.content.replace("&", "&").replace("<", "<").replace(">", ">")
+ content = _content_to_text(m.content).replace("&", "&").replace("<", "<").replace(">", ">")
content = content.replace("\n", "
")
html_parts.append(f'