2026-05-31 23:58:26 +09:00
|
|
|
# src/constants.py
|
|
|
|
|
"""Application-wide constants and configuration values."""
|
|
|
|
|
import os
|
|
|
|
|
|
2026-06-15 19:44:10 +03:00
|
|
|
from src.runtime_paths import get_app_root, get_default_data_dir
|
|
|
|
|
|
2026-06-19 00:34:37 +00:00
|
|
|
APP_VERSION = "1.0.1"
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
# Base paths
|
2026-06-15 19:44:10 +03:00
|
|
|
BASE_DIR = os.path.join(get_app_root(), "")
|
2026-05-31 23:58:26 +09:00
|
|
|
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
2026-06-15 19:44:10 +03:00
|
|
|
DATA_DIR = os.getenv("ODYSSEUS_DATA_DIR", get_default_data_dir())
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
# Data file paths
|
2026-06-08 09:58:52 +02:00
|
|
|
# Single source of truth: every persisted file/dir lives under DATA_DIR, which
|
|
|
|
|
# is the ONLY place ODYSSEUS_DATA_DIR is read. Import these constants instead of
|
|
|
|
|
# re-deriving paths from __file__ or a relative "data" literal.
|
2026-05-31 23:58:26 +09:00
|
|
|
SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json")
|
|
|
|
|
MEMORY_FILE = os.path.join(DATA_DIR, "memory.json")
|
|
|
|
|
MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md")
|
|
|
|
|
PERSONAL_DIR = os.path.join(DATA_DIR, "personal_docs")
|
|
|
|
|
RUNBOOK_DIR = os.path.join(PERSONAL_DIR, "runbook")
|
|
|
|
|
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
|
|
|
|
|
FEATURES_FILE = os.path.join(DATA_DIR, "features.json")
|
|
|
|
|
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
2026-06-08 09:58:52 +02:00
|
|
|
AUTH_FILE = os.path.join(DATA_DIR, "auth.json")
|
|
|
|
|
USER_PREFS_FILE = os.path.join(DATA_DIR, "user_prefs.json")
|
|
|
|
|
PRESETS_FILE = os.path.join(DATA_DIR, "presets.json")
|
|
|
|
|
INTEGRATIONS_FILE = os.path.join(DATA_DIR, "integrations.json")
|
|
|
|
|
CONTACTS_FILE = os.path.join(DATA_DIR, "contacts.json")
|
|
|
|
|
APP_KEY_FILE = os.path.join(DATA_DIR, ".app_key")
|
|
|
|
|
EMBEDDING_ENDPOINT_FILE = os.path.join(DATA_DIR, "embedding_endpoint.json")
|
|
|
|
|
COOKBOOK_STATE_FILE = os.path.join(DATA_DIR, "cookbook_state.json")
|
|
|
|
|
BG_JOBS_FILE = os.path.join(DATA_DIR, "bg_jobs.json")
|
|
|
|
|
VAULT_FILE = os.path.join(DATA_DIR, "vault.json")
|
|
|
|
|
TIDY_CALENDAR_STATE_FILE = os.path.join(DATA_DIR, "tidy_calendar_state.json")
|
|
|
|
|
SKILLS_FILE = os.path.join(DATA_DIR, "skills.json")
|
|
|
|
|
APP_DB = os.path.join(DATA_DIR, "app.db")
|
|
|
|
|
SCHEDULED_EMAILS_DB = os.path.join(DATA_DIR, "scheduled_emails.db")
|
|
|
|
|
EMAIL_CACHE_DB = os.path.join(DATA_DIR, "email_cache.db")
|
|
|
|
|
|
|
|
|
|
# Data subdirectories
|
|
|
|
|
PERSONAL_UPLOADS_DIR = os.path.join(DATA_DIR, "personal_uploads")
|
|
|
|
|
EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache")
|
|
|
|
|
RAG_DIR = os.path.join(DATA_DIR, "rag")
|
|
|
|
|
CHROMA_DIR = os.path.join(DATA_DIR, "chroma")
|
|
|
|
|
BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs")
|
|
|
|
|
DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research")
|
|
|
|
|
MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth")
|
|
|
|
|
GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images")
|
|
|
|
|
TTS_CACHE_DIR = os.path.join(DATA_DIR, "tts_cache")
|
|
|
|
|
EMAIL_URGENCY_CACHE_DIR = os.path.join(DATA_DIR, "email_urgency_cache")
|
|
|
|
|
SKILLS_DIR = os.path.join(DATA_DIR, "skills")
|
|
|
|
|
GALLERY_DIR = os.path.join(DATA_DIR, "gallery")
|
|
|
|
|
GALLERY_UPLOADS_DIR = os.path.join(DATA_DIR, "gallery_uploads")
|
|
|
|
|
MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors")
|
|
|
|
|
|
|
|
|
|
# Paths with an intentional dedicated env override, defaulting under DATA_DIR.
|
|
|
|
|
MAIL_ATTACHMENTS_DIR = os.getenv("ODYSSEUS_MAIL_ATTACHMENTS_DIR", os.path.join(DATA_DIR, "mail-attachments"))
|
2026-06-16 03:11:48 +01:00
|
|
|
# `or` (not os.getenv's default arg) so a PRESENT-but-EMPTY value falls back to
|
|
|
|
|
# the default. docker-compose.yml injects `FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}`,
|
|
|
|
|
# which sets the var to "" when the host hasn't defined it. os.getenv(name, default)
|
|
|
|
|
# only returns the default when the var is ABSENT, so the empty string would win →
|
|
|
|
|
# os.makedirs("") raises [Errno 2] No such file or directory: '' → FastEmbed fails to
|
|
|
|
|
# init and all vector features (RAG, semantic memory, tool index) silently degrade.
|
|
|
|
|
FASTEMBED_CACHE_DIR = os.getenv("FASTEMBED_CACHE_PATH") or os.path.join(DATA_DIR, "fastembed_cache")
|
2026-05-31 23:58:26 +09:00
|
|
|
|
2026-06-05 18:05:02 -03:00
|
|
|
# Agent tool output limits (single source of truth — imported by tool_execution.py,
|
|
|
|
|
# tool_implementations.py, agent_tools.py, and any other module that needs them)
|
|
|
|
|
MAX_OUTPUT_CHARS = 10_000 # cap for bash/python/web_search/web_fetch output
|
|
|
|
|
MAX_READ_CHARS = 20_000 # cap for read_file / document preview
|
|
|
|
|
MAX_DIFF_LINES = 400 # cap for edit_file unified-diff display
|
|
|
|
|
|
fix(search): add download budgets to web_fetch with truncation notice and hard ceiling (#3955)
* fix(search): add download budgets to web_fetch with truncation notice and hard ceiling
MAX_OUTPUT_CHARS only trims what the agent sees; fetch_webpage_content
buffered and cached the entire response body first, so a large or hostile
URL could pull arbitrarily many bytes into memory and the content cache.
The fetch is now a capped streaming GET (SSRF redirect guard unchanged):
a soft default budget (WEB_FETCH_SOFT_MAX_BYTES, 2 MB), a per-call
override via full/max_bytes on the web_fetch tool, and a hard ceiling
(WEB_FETCH_HARD_MAX_BYTES, 20 MB) that the override can never exceed.
When Content-Length already declares a body over the ceiling the fetch
is refused before any body bytes are buffered. Truncated results carry
truncated/fetched_bytes/total_bytes, the tool output leads with a
partial-content notice telling the model how to re-fetch with full=true,
and the tool schema documents the flag. A truncated PDF is reported as
a budget error since a cut PDF is unparseable. The effective cap is part
of the content-cache key so a truncated fetch is never served to a
full-budget request.
Existing tests that faked httpx.get or the old _get_public_url signature
are adapted to the streaming interface; behavior pins are unchanged.
Fixes #3812
* fix(search): close compressed-body cap bypass and protect the partial notice
Addresses RaresKeY's review on #3955:
- Force Accept-Encoding: identity for the capped fetch. With gzip/deflate the
wire bytes (and Content-Length) can be a fraction of the decoded body, so a
tiny compressed response could pass the hard-cap preflight and then expand
past the ceiling in a single decoded chunk before the streamed cap could
slice it. Identity makes Content-Length the true body size and keeps each
streamed chunk bounded by the network read, so the hard ceiling actually
bounds memory.
- Lead web_fetch output with the partial-content notice and cap the page
title. The notice is the user-facing contract for partial fetches, but the
title is untrusted, uncapped page content; placed ahead of the notice a giant
title could push it past MAX_OUTPUT_CHARS and drop it. The notice now leads
and the title is capped as a second guard.
Adds regressions: the fetch advertises identity encoding, and a truncated
result with an oversized title still surfaces the partial notice.
* fix(search): reject compressed responses that ignore the identity request
Requesting Accept-Encoding: identity is not enough on its own: a server can
ignore it and still return Content-Encoding: gzip, and httpx.iter_bytes would
decode that, so a tiny compressed body could balloon into one decoded chunk
far past the hard cap before the streamed loop slices it (and Content-Length,
the compressed wire length, makes the preflight and size metadata unreliable).
Refuse a non-identity Content-Encoding before reading the body. Adds a
regression where the server ignores the identity request and returns gzip;
the fetch is refused before any body is decoded.
2026-06-15 19:38:09 +02:00
|
|
|
# web_fetch response-size policy (#3812). MAX_OUTPUT_CHARS above only trims
|
|
|
|
|
# what the agent SEES; these caps bound what the server downloads, parses,
|
|
|
|
|
# and writes to the content cache. The soft cap is the default download
|
|
|
|
|
# budget; the agent can raise it per call (full/max_bytes) but never past
|
|
|
|
|
# the hard cap, so a model can't decide to pull a multi-GB file.
|
|
|
|
|
WEB_FETCH_SOFT_MAX_BYTES = 2_000_000 # default download budget (2 MB)
|
|
|
|
|
WEB_FETCH_HARD_MAX_BYTES = 20_000_000 # absolute ceiling, even with override (20 MB)
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
# API Configuration
|
|
|
|
|
MAX_CONTEXT_MESSAGES = 90
|
|
|
|
|
REQUEST_TIMEOUT = 20
|
|
|
|
|
OPENAI_COMPAT_PATH = "/v1/chat/completions"
|
|
|
|
|
|
2026-06-16 03:33:47 +02:00
|
|
|
# Outbound UA for web_fetch / web_search scraping; common desktop UA so pages serve normal HTML.
|
|
|
|
|
WEB_FETCH_USER_AGENT = os.environ.get(
|
|
|
|
|
"WEB_FETCH_USER_AGENT",
|
|
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
|
|
|
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
# Environment variables with defaults
|
|
|
|
|
DEFAULT_HOST = os.getenv("LLM_HOST", "localhost")
|
|
|
|
|
LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()]
|
|
|
|
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
2026-06-05 18:05:02 -03:00
|
|
|
SEARXNG_INSTANCE = os.getenv("SEARXNG_INSTANCE", "http://localhost:8080")
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Cleanup configuration
|
|
|
|
|
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
|
|
|
|
|
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
|
|
|
|
|
2026-06-16 02:52:15 -05:00
|
|
|
# Auth policy
|
|
|
|
|
PASSWORD_MIN_LENGTH = 8
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
# Default parameters
|
|
|
|
|
DEFAULT_TEMPERATURE = 1.0
|
|
|
|
|
DEFAULT_MAX_TOKENS = 0
|
2026-06-08 09:58:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def internal_api_base() -> str:
|
|
|
|
|
"""Base URL for in-process loopback calls to Odysseus's own API.
|
|
|
|
|
|
|
|
|
|
Agent tools and background jobs reach admin-gated routes by calling the
|
|
|
|
|
running server over HTTP. Resolution order:
|
|
|
|
|
1. ODYSSEUS_INTERNAL_BASE - explicit override (e.g. behind a TLS proxy).
|
|
|
|
|
2. APP_PORT - http://127.0.0.1:$APP_PORT (docker-compose).
|
|
|
|
|
3. Fallback http://127.0.0.1:7000 - legacy default.
|
|
|
|
|
|
|
|
|
|
127.0.0.1 (not "localhost") avoids IPv6/DNS ambiguity for a strictly-local
|
|
|
|
|
call. Without this, loopback tools fail with "All connection attempts
|
|
|
|
|
failed" whenever the server is not on port 7000.
|
|
|
|
|
"""
|
|
|
|
|
override = os.environ.get("ODYSSEUS_INTERNAL_BASE")
|
|
|
|
|
if override:
|
|
|
|
|
return override.rstrip("/")
|
|
|
|
|
return f"http://127.0.0.1:{os.environ.get('APP_PORT', '7000')}"
|