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>
126 lines
7.6 KiB
Python
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",
|
|
}
|