Thicket/thicket/layout.py

110 lines
3.9 KiB
Python

"""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 <AI_ROOT>/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"