110 lines
3.9 KiB
Python
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"
|