"""AI workstation layout awareness. /mnt/AI is the canonical AI filesystem: a deliberate taxonomy where corpus/cold is the incoming raw pile, corpus/hot is the active brain ("indexed in Vector DBs and used by agents" — Thicket's vault concept: notes + .thicket/ vector data + graphs in one portable tree), and corpus/books holds the standing library. When the layout exists, Thicket adopts its paths as defaults (IN, VAULT, archive) and the probe reports what it found. When it does not, home-directory defaults apply — behavior is identical either way. ``expand_path`` is the single point where user-supplied paths expand ``~`` and ``$VAR`` references — the GUI, CLI, and core all resolve identically. """ from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path # Root override for tests / alternative hosts. AI_ROOT = Path(os.environ.get("THICKET_AI_ROOT", "/mnt/AI")) # Canonical subtrees Thicket cares about. The corpus trio carries the # ingest flow; the rest are reported for context. @dataclass(frozen=True, slots=True) class LayoutProfile: """Resolved view of the AI filesystem — only what exists.""" root: Path corpus_cold: Path # incoming / raw corpus_hot: Path # active brain (vault default) corpus_books: Path # standing library corpus_obsidian: Path # dedicated vault alternative archive: Path # ingested-archive home books_present: bool def apply_backend_profiles() -> dict[str, str]: """Load connection profiles from /backends/thicket.env. Precedence is unix-ordered: the real environment wins (variables already set are untouched), the profile fills the gaps, built-in defaults apply last. KEY=VALUE lines; blank lines and # comments ignored. Returns the variables the file contributed. """ profile = AI_ROOT / "backends" / "thicket.env" if not profile.is_file(): return {} applied: dict[str, str] = {} for line in profile.read_text(encoding="utf-8", errors="ignore").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key, value = key.strip(), value.strip().strip('"').strip("'") if key and key not in os.environ: os.environ[key] = value applied[key] = value return applied def expand_path(value: str | Path) -> Path: """Expand ~ and $VAR references to a concrete Path.""" return Path(os.path.expandvars(os.path.expanduser(str(value)))) def detect_layout() -> LayoutProfile | None: """The layout profile when the corpus taxonomy exists, else None.""" corpus = AI_ROOT / "corpus" if not corpus.is_dir(): return None return LayoutProfile( root=AI_ROOT, corpus_cold=corpus / "cold", corpus_hot=corpus / "hot", corpus_books=corpus / "books", corpus_obsidian=corpus / "obsidian", archive=corpus / "archive", books_present=(corpus / "books").is_dir(), ) def default_input() -> Path: """IN default: the cold corpus when the layout exists.""" profile = detect_layout() if profile and profile.corpus_cold.is_dir(): return profile.corpus_cold return Path.home() / "Downloads" / "Raw_Books_And_Papers" def default_vault() -> Path: """VAULT default: the hot corpus — one tree holding notes, .thicket/ vector data, and the knowledge graphs.""" profile = detect_layout() if profile: return profile.corpus_hot return Path.home() / "Documents" / "ObsidianVault" def default_archive(input_dir: Path) -> Path: """Archive default: corpus/archive inside the layout, else the ingested-archive sibling of the input tree.""" profile = detect_layout() if profile: return profile.archive return input_dir.parent / "ingested-archive"