"""The knowledge vault (architecture L10; Development Plan M5.1). A folder of Markdown notes on the agent host, in the plain format SilverBullet reads (frontmatter, ``[[links]]``, ``#tags``), so the operator browses it there. - **Only configured folders exist** for the agent, each read-write or read-only; every folder has one writer. Paths are checked in code before anything is touched: relative, inside an allowed folder (symlinks included), Markdown, and never the editor's own files. Per-agent mounts enforce the same at the OS level once the agent runs in a container (M6.1). - **Writes are atomic** (write a temporary file beside the note, sync it, rename it into place), so a crash, SilverBullet or anyone reading the folder never sees half a note, and they **record provenance** in the note's frontmatter. - **Failures are results, never raised into the loop:** a failed read or write never blocks work. - **Notes are data, never instructions** (L10, L11): the tools label what they return with its path. """ import fnmatch import os import tempfile from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path, PurePosixPath from typing import Any import yaml READ_WRITE = "rw" READ_ONLY = "ro" ACCESS = (READ_WRITE, READ_ONLY) # The editor's and tools' own files: hidden ones (SilverBullet's and # Obsidian's settings, .git, a write's temporary file) and SilverBullet's plugs. DEFAULT_IGNORE = (".*", "_plug") NOTE_SUFFIX = ".md" MAX_NOTE_CHARS = 20_000 # what one read returns; the rest is said to be cut MAX_LISTED = 200 MAX_FOUND = 20 LINES_PER_NOTE = 3 class VaultError(Exception): """What a vault operation couldn't do, in words for the model.""" @dataclass(frozen=True) class Found: path: str lines: tuple[str, ...] # the matching lines, first few hits: int class Vault: """``folders``: vault-relative folder -> "rw" or "ro". Nothing outside them is visible.""" def __init__( self, root: Path, folders: Mapping[str, str], *, ignore: Sequence[str] = DEFAULT_IGNORE, agent: str = "nisse-dev", clock: Any = None, ) -> None: self.root = Path(root) self._folders = {_clean(f): a for f, a in folders.items()} self._ignore = tuple(ignore) self._agent = agent self._clock = clock or (lambda: datetime.now(UTC)) for folder, access in self._folders.items(): if access == READ_WRITE: (self.root / folder).mkdir(parents=True, exist_ok=True) def folders(self) -> dict[str, str]: return dict(self._folders) # -- paths ----------------------------------------------------------------- def _folder_of(self, path: PurePosixPath) -> str | None: for folder in self._folders: if path == PurePosixPath(folder) or PurePosixPath(folder) in path.parents: return folder return None def _hidden(self, path: PurePosixPath) -> bool: return any( fnmatch.fnmatchcase(part, pattern) for part in path.parts for pattern in self._ignore ) def _resolve(self, path: str, *, note: bool) -> tuple[PurePosixPath, Path, str]: """The checked path, where it is on disk, and its folder's access.""" rel = _relative(path) folder = self._folder_of(rel) if folder is None: raise VaultError( f"{path}: not in a folder you can see; they are " + ", ".join(sorted(self._folders)) ) if self._hidden(rel): raise VaultError(f"{path}: the editor's own files aren't notes") if note and rel.suffix != NOTE_SUFFIX: raise VaultError(f"{path}: notes are Markdown files ending in .md") real = (self.root / rel).resolve() top = (self.root / folder).resolve() if real != top and top not in real.parents: raise VaultError(f"{path}: leads outside its folder") return rel, real, self._folders[folder] # -- reading --------------------------------------------------------------- def read(self, path: str) -> str: _, real, _ = self._resolve(path, note=True) try: text = real.read_text(encoding="utf-8") except FileNotFoundError: raise VaultError(f"{path}: no such note") from None except (OSError, UnicodeDecodeError) as exc: raise VaultError(f"{path}: couldn't read it ({exc})") from None if len(text) > MAX_NOTE_CHARS: cut = len(text) - MAX_NOTE_CHARS text = f"{text[:MAX_NOTE_CHARS]}\n\n[{cut} more characters not shown]" return text def notes(self, folder: str = "") -> list[str]: """Notes under ``folder`` (every visible folder when empty).""" tops = ( [self._resolve(folder, note=False)[0]] if folder.strip("/ ") else [PurePosixPath(f) for f in sorted(self._folders)] ) return sorted(p for top in tops for p in self._notes_under(top)) def _notes_under(self, top: PurePosixPath) -> list[str]: base = (self.root / top).resolve() if not base.is_dir(): return [] found = [] for dirpath, dirnames, filenames in os.walk(base): here = PurePosixPath(Path(dirpath).relative_to(self.root.resolve())) dirnames[:] = sorted(d for d in dirnames if not self._hidden(here / d)) for name in sorted(filenames): rel = here / name if rel.suffix != NOTE_SUFFIX or self._hidden(rel): continue real = (self.root / rel).resolve() if real == base or base in real.parents: # no links out found.append(str(rel)) return found def search(self, text: str) -> list[Found]: """Notes whose path or text contains ``text``, ignoring case; most matches first.""" needle = text.strip().lower() if not needle: raise VaultError("search for some text") found = [] for path in self.notes(): try: _, real, _ = self._resolve(path, note=True) body = real.read_text(encoding="utf-8") except (VaultError, OSError, UnicodeDecodeError): continue # a failed read never blocks the rest lines = [ln.strip() for ln in body.splitlines() if needle in ln.lower()] hits = len(lines) + (needle in path.lower()) if hits: found.append(Found(path, tuple(lines[:LINES_PER_NOTE]), hits)) found.sort(key=lambda f: (-f.hits, f.path)) return found[:MAX_FOUND] # -- writing --------------------------------------------------------------- def delete(self, path: str) -> None: """Remove a note in a read-write folder (one writer: its own).""" _, real, access = self._resolve(path, note=True) if access != READ_WRITE: raise VaultError(f"{path}: that folder is read-only for you") try: real.unlink() except FileNotFoundError: raise VaultError(f"{path}: no such note") from None except OSError as exc: raise VaultError(f"{path}: couldn't remove it ({exc})") from None def write(self, path: str, text: str, *, provenance: Mapping[str, str]) -> None: """Write a note in a read-write folder, atomically, with where it came from merged into its frontmatter; the rest of its frontmatter is kept.""" _, real, access = self._resolve(path, note=True) if access != READ_WRITE: raise VaultError(f"{path}: that folder is read-only for you") front, body = split_frontmatter(text) front["provenance"] = { "by": self._agent, **dict(provenance), "written": self._clock().isoformat(timespec="seconds"), } content = ( "---\n" + yaml.safe_dump(front, sort_keys=False, allow_unicode=True) + "---\n" + body ) try: real.parent.mkdir(parents=True, exist_ok=True) _replace(real, content) except OSError as exc: raise VaultError(f"{path}: couldn't write it ({exc})") from None def split_frontmatter(text: str) -> tuple[dict[str, Any], str]: """A note's frontmatter (a YAML mapping between --- lines at the top) and the rest. Raises VaultError for frontmatter that isn't a mapping.""" if not text.startswith("---\n"): return {}, text end = text.find("\n---\n", 4) if end == -1: return {}, text try: front = yaml.safe_load(text[4:end]) or {} except yaml.YAMLError as exc: raise VaultError(f"its frontmatter isn't valid YAML ({exc})") from None if not isinstance(front, dict): raise VaultError("its frontmatter must be a mapping") return front, text[end + 5 :] def _relative(path: str) -> PurePosixPath: rel = PurePosixPath(path.strip()) if not path.strip() or rel.is_absolute() or ".." in rel.parts or "\\" in path: raise VaultError(f"{path!r}: give a path inside the vault, like notes/x.md") return rel def _clean(folder: str) -> str: return str(_relative(folder.strip("/"))) def _replace(target: Path, content: str) -> None: """Write-then-rename in the note's own directory (L10): readers see the old note or the new one, never part of either. The temporary file is hidden, so it is never listed, and removed if anything fails.""" fd, temp = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.") try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(temp, target) except BaseException: Path(temp).unlink(missing_ok=True) raise directory = os.open(target.parent, os.O_RDONLY) try: os.fsync(directory) # the rename itself survives a crash finally: os.close(directory)