Files
ai-agent/backend/core/config.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

126 lines
7.6 KiB
Python

"""Environment, paths and constants shared by every backend package."""
import os
import pathlib
import re
import accounts as accounts_mod
WORKSPACE = pathlib.Path(os.environ.get("WORKSPACE", "/workspace")).resolve()
STATIC_DIR = pathlib.Path(os.environ.get("STATIC_DIR", "/app/static")).resolve()
# Screenshots captured by the `screenshot` skill (docs/screenshots/, mounted
# read-only). Served by /api/screenshot and shown inline in the thread viewer.
SCREENSHOTS_DIR = pathlib.Path(
os.environ.get("SCREENSHOTS_DIR", "/screenshots")).resolve()
SCREENSHOT_EXTS = {".jpeg", ".jpg", ".png", ".webp", ".gif"}
# The repo's root `data/` tree (mounted read-only at /workspace/data). Generated
# artifacts (images, renders, charts) land here and are served by /api/data-asset
# so a `Read` of an image under data/ can be previewed inline in the thread.
DATA_DIR = (WORKSPACE / "data")
# Claude Code's per-session scratchpad root (mounted read-only). A session's
# temp dir is <root>/<encoded-cwd>/<sessionId>/scratchpad/…, which is where a
# run's throwaway renders and screenshots land when they aren't worth committing
# to a project's .ai/artefacts/. Served by /api/scratchpad-asset so those still
# preview inline in the thread instead of showing "not previewable".
SCRATCHPAD_DIR = pathlib.Path(
os.environ.get("SCRATCHPAD_DIR", "/scratchpads")).resolve()
IMAGE_EXTS = {".jpeg", ".jpg", ".png", ".webp", ".gif", ".svg", ".bmp", ".avif"}
# Files uploaded from the composer (SpawnBox/ResumeBox). Stored inside the
# service's own writable data dir; served back by /api/upload-file and read by
# the spawned `claude -p` session. UPLOADS_DIR is the container path; the repo
# prefix is what the host-side session (cwd = repo root) sees, so the composer
# injects "<prefix>/<batch>/<name>" into the prompt for Claude to Read.
UPLOADS_DIR = pathlib.Path(
os.environ.get("UPLOADS_DIR", "/data/uploads")).resolve()
UPLOADS_REPO_PREFIX = os.environ.get(
"UPLOADS_REPO_PREFIX", "services/ai-agent/data/uploads").strip("/")
MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", 50 * 1024 * 1024))
# Custom avatar sets imported as a zip (sprite editor's "Export set"). Unpacked
# into the writable data volume and served at /avatars/custom/ so the roaming
# avatar can play a full custom emote set (not just a single strip URL). One
# active set at a time.
AVATARS_DIR = pathlib.Path(
os.environ.get("AVATARS_DIR", "/data/avatars")).resolve()
CUSTOM_AVATAR_DIR = AVATARS_DIR / "custom"
MAX_AVATAR_ZIP_BYTES = int(os.environ.get("MAX_AVATAR_ZIP_BYTES", 40 * 1024 * 1024))
_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
DB_PATH = os.environ.get("DB_PATH", "/data/ai-agent.db")
_transcripts = os.environ.get("TRANSCRIPTS_DIR", "/transcripts")
# Live (read-only) Claude Code transcripts that get imported into the archive.
SOURCE_DIR = pathlib.Path(_transcripts).resolve() if _transcripts else None
# Second live source: transcripts mirrored by the pi-harness runner
# (services/ai-agent/runner writes Claude-Code-schema JSONL there).
_pi_transcripts = os.environ.get("PI_TRANSCRIPTS_DIR", "/pi-transcripts")
PI_SOURCE_DIR = (pathlib.Path(_pi_transcripts).resolve()
if _pi_transcripts else None)
# Third live source: the in-container runner (RUNNER_IN_CONTAINER=1, see
# docker-entrypoint.sh). The Claude Code CLI baked into the image writes its
# transcripts under the runner's own $HOME, inside the container — watching that
# dir is what makes an in-container session stream into the viewer like any other.
# Unset on the homelab, where the runner is the host sidecar and its transcripts
# arrive through the read-only /transcripts mount instead.
_runner_transcripts = os.environ.get("RUNNER_TRANSCRIPTS_DIR", "")
RUNNER_SOURCE_DIR = (pathlib.Path(_runner_transcripts).resolve()
if _runner_transcripts else None)
# Fourth: every non-default Claude account's own transcripts (accounts.py) —
# the work seat's CLI writes under ~/.claude-work/projects, mounted read-only
# at WORK_TRANSCRIPTS_DIR. Imported into the same archive; the indexer records
# which account each one came from (``meta.accountSource``).
ACCOUNT_SOURCE_DIRS = accounts_mod.transcripts_dirs() # {account: dir}
# Fifth: remote workers' transcripts (workers.py), pulled over each worker's
# /feed by the FeedMirror into this one dir with Claude Code's own layout. The
# mirror stamps which worker (and so which account) each session came from.
_workers_transcripts = os.environ.get("WORKERS_TRANSCRIPTS_DIR",
"/data/workers/transcripts")
WORKERS_SOURCE_DIR = (pathlib.Path(_workers_transcripts).resolve()
if _workers_transcripts else None)
# dict.fromkeys: order-preserving dedupe — pointing two of these at the same dir
# would otherwise import every transcript in it twice.
SOURCE_DIRS = list(dict.fromkeys(
d for d in (SOURCE_DIR, PI_SOURCE_DIR, RUNNER_SOURCE_DIR,
*ACCOUNT_SOURCE_DIRS.values(), WORKERS_SOURCE_DIR) if d))
# Persistent archive: imported transcripts live here and are indexed/read from
# it (so conversations Claude later prunes from the source survive).
ARCHIVE_DIR = pathlib.Path(
os.environ.get("ARCHIVE_DIR", "/data/transcripts")).resolve()
TRANSCRIPTS_DIR = ARCHIVE_DIR # the detail endpoint reads from the archive
# Sidecar of per-conversation action metadata edited by the skill scripts.
META_PATH = os.environ.get("META_PATH", "/data/conversations-meta.json")
# Cron jobs: scheduled agent sessions spawned from prompt files (see cron.py).
CRON_PATH = os.environ.get("CRON_PATH", "/data/cron-jobs.json")
# First-class notifications: webhook config + notification/ask log (notify.py).
NOTIFY_PATH = os.environ.get("NOTIFY_PATH", "/data/notifications.json")
FORMS_PATH = os.environ.get("FORMS_PATH", "/data/forms.json")
# Mail trigger allowlist (mail_trigger.py) — read by the mail service's
# container off the same shared /data volume; see MailTriggerStore.
MAIL_TRIGGER_SENDERS_PATH = os.environ.get(
"MAIL_TRIGGER_SENDERS_PATH", "/data/mail-trigger-senders.json")
# Server-side store for the PWA's small client state (Settings config + the
# notification feed's seen/new bookkeeping) — moved off browser localStorage so
# it follows the user across browsers/devices. See ui_state.py.
UI_STATE_PATH = os.environ.get("UI_STATE_PATH", "/data/ui-state.json")
# Paired remote workers (workers.py) — url + bearer each, so 0600.
WORKERS_PATH = os.environ.get("WORKERS_PATH", "/data/workers.json")
# Named in the worker's logs at pairing ("paired with homelab").
HUB_NAME = os.environ.get("HUB_NAME", "homelab")
# A conversation whose last activity is within this window counts as "running".
RUNNING_WINDOW_SECS = float(os.environ.get("RUNNING_WINDOW_SECS", "600"))
# Host-side sidecar that actually launches `claude -p` (see services/ai-agent/
# sidecar/). The container reaches it over host.docker.internal.
SIDECAR_URL = os.environ.get("SIDECAR_URL", "http://host.docker.internal:8790")
SIDECAR_TOKEN = os.environ.get("SIDECAR_TOKEN", "")
# Directories never worth scanning (huge / generated / vendored).
IGNORE_DIRS = {
".git", "node_modules", ".forge", "dist", "build", "__pycache__",
".venv", "venv", ".cache", "history", "tmp", ".next", "coverage",
}
MD_EXTS = {".md", ".markdown"}
# Text-ish files worth surfacing from the repo's root `data/` tree (plans, logs,
# notes, kanban, widget templates). Binary/image files (screenshots, thumbnails)
# are skipped — the viewer is a text editor and can't render them.
DATA_TEXT_EXTS = {
".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".toml", ".ini",
".cfg", ".py", ".sh", ".js", ".ts", ".jinja", ".j2", ".log", ".csv",
}