mattrguerra/better-memory-pipelinemainPrivate on GitHub, published here in full

gate.py

Language Python13.8 KB384 lines

gate.py

  1. #!/usr/bin/env python3
  2. """
  3. gate.py: the deterministic path gate in front of the better-memory distiller.
  4. A plain script, not a model. Each run wipes STAGING_DIR/staging, walks the vaults
  5. with a fail-closed denylist (checked after resolving traversal and symlinks), skips
  6. vault scaffolding, hashes each safe note's post-gate content and stages only the
  7. notes whose hash differs from the manifest. A manifest of content hashes, rather
  8. than a record of seen notes, means an edited note always re-stages.
  9. Env: VAULT_ROOTS (colon-separated vault paths), STAGING_DIR (staging root, default
  10. ~/cowork-staging), EXTRA_DENY (comma-separated extra denied prefixes, optional).
  11. """
  12. from __future__ import annotations
  13. import os
  14. import re
  15. import json
  16. import shutil
  17. import hashlib
  18. from pathlib import Path
  19. # --- Configuration
  20. VAULT_ROOTS = [
  21. Path(p).resolve()
  22. for p in os.environ.get("VAULT_ROOTS", "").split(":")
  23. if p.strip()
  24. ]
  25. STAGING_ROOT = Path(os.environ.get("STAGING_DIR", str(Path.home() / "cowork-staging"))).resolve()
  26. STAGING_DIR = STAGING_ROOT / "staging"
  27. MANIFEST_PATH = STAGING_ROOT / ".manifest.json"
  28. # Fail-closed denylist. Prefixes are anchored to a vault root; components match a
  29. # folder name at any depth. The destination is a private database only the owner
  30. # reads, so the list covers genuinely private content; research folders may stage.
  31. _BASE_DENY_PREFIXES = [
  32. "_private", # the wall
  33. "efforts/finance", # financial records
  34. "efforts/journal", # the personal journal, whole tree
  35. ]
  36. DENY_COMPONENTS = [
  37. ".obsidian", # plugin configs, which can hold API keys: root and nested vaults
  38. ".git", # repo internals, at any depth (nested vaults can be repos)
  39. ".trash", # Obsidian deleted-notes, any depth
  40. "_excalidraw", # excalidraw scratch dirs, any depth
  41. ".basic-memory", # Basic Memory's SQLite index + internal state
  42. ".venv", # virtualenv noise
  43. ]
  44. _EXTRA_DENY = [
  45. p.strip().lower().strip("/")
  46. for p in os.environ.get("EXTRA_DENY", "").split(",")
  47. if p.strip()
  48. ]
  49. DENY_PREFIXES = _BASE_DENY_PREFIXES + _EXTRA_DENY
  50. # Only these are ever staged.
  51. STAGE_SUFFIXES = {".md", ".markdown", ".txt"}
  52. # --- Scaffolding: vault machinery that is not memory about the owner
  53. #
  54. # Operational instructions, changelogs, model guides, run logs and templates are
  55. # not facts about the owner, and distilling them pollutes recall. They are skipped,
  56. # except that each nested vault keeps one explainer (see EXPLAINER_PRIORITY) so it
  57. # still contributes a "what is this vault for" note.
  58. # Exact filenames (case-insensitive, basename match) that are always scaffolding.
  59. SCAFFOLD_FILENAMES = {
  60. "heartbeat.md", "setup.md", "model_guide.md", "changelog.md",
  61. "skills_reference.md", "subagents.md", "memory.md",
  62. "knowledge_index.md", "inventory_index.md", "topics_index.md",
  63. "sources_index.md",
  64. # Project plumbing. decisions.md is kept for _meta only; see SCAFFOLD_KEEP_PATHS.
  65. "decisions.md", "skill.md", "agent.md", "automation.md",
  66. "backlog.md", "investigation-plan.md",
  67. "test.md", "scratch.md", "url-inventory.md",
  68. }
  69. # Also scaffolding by pattern: per-folder instruction files (claude.md, claude-*.md),
  70. # index files (NN-index.md, *_index.md, *-index.md), done and deprecated notes, probe
  71. # files and bare-date daily notes. A vault's chosen explainer is kept regardless.
  72. def _is_scaffold_name(basename: str) -> bool:
  73. b = basename.lower()
  74. if b in SCAFFOLD_FILENAMES:
  75. return True
  76. if b == "claude.md" or b.startswith("claude-"):
  77. return True
  78. if re.match(r"^\d{2}-index\.md$", b) or b.endswith("_index.md") or b.endswith("-index.md"):
  79. return True
  80. if "deprecated" in b:
  81. return True
  82. if b.endswith("-block-done.md") or b.endswith("-done.md"):
  83. return True
  84. if re.match(r"^probe\d*\.txt$", b):
  85. return True
  86. # bare date-named daily scratch at any level (e.g. 2026-06-17.md), but not
  87. # dated content notes that have a descriptive suffix (those keep their words)
  88. if re.match(r"^\d{4}-\d{2}-\d{2}\.md$", b):
  89. return True
  90. return False
  91. # Directory segments whose notes are templates, run logs, session transcripts or
  92. # scratch. A note whose vault-relative path contains one is skipped wholesale.
  93. SCAFFOLD_PATH_FRAGMENTS = (
  94. "/_templates/",
  95. "/08 - templates/",
  96. "/02-run-logs/",
  97. "/01-sessions/",
  98. "/initiation-prompts/",
  99. "/_scratch/",
  100. "/_scratch-junk/",
  101. )
  102. # A scaffolding-named file is kept when its lowercased vault-relative path ends with
  103. # one of these: decisions.md is plumbing everywhere except the _meta identity layer,
  104. # where it records the owner's system decisions.
  105. SCAFFOLD_KEEP_PATHS = (
  106. "_meta/decisions.md",
  107. )
  108. # When picking the single explainer note for a nested vault, prefer in this order.
  109. EXPLAINER_PRIORITY = ("readme.md", "00-index.md", "claude.md")
  110. # A "nested vault" for the one-explainer rule = an immediate child directory of
  111. # one of these parents (relative to a vault root). Each such folder keeps exactly
  112. # one explainer; the root vault itself also keeps one.
  113. EXPLAINER_PARENT_DIRS = ("efforts", "atlas")
  114. # --- Section gate: stage only a note's framing when it mixes in sensitive detail
  115. #
  116. # Off by design: both lists are empty, so every note stages whole. Private material
  117. # is already path-denied before it reaches this point, and the patterns only ever
  118. # truncated benign notes. Adding a pattern or a link name re-enables it.
  119. def _alternation_or_never(patterns: list[str], before: str = "",
  120. after: str = "") -> re.Pattern[str]:
  121. """Compile `before + "|".join(patterns) + after`, case-insensitive."""
  122. # Guard: an empty alternation matches everywhere (for the link pattern, every
  123. # "[["), framing everything. When the list is empty, use a pattern that can
  124. # never match.
  125. if not patterns:
  126. return re.compile(r"(?!x)x")
  127. return re.compile(before + "|".join(patterns) + after, re.IGNORECASE)
  128. SENSITIVE_PATTERNS: list[str] = []
  129. _SENSITIVE_RE = _alternation_or_never(SENSITIVE_PATTERNS)
  130. # Wikilink targets whose presence marks a note as mixed.
  131. EXCLUDED_LINK_NAMES: list[str] = []
  132. _EXCLUDED_LINK_RE = _alternation_or_never(
  133. [re.escape(n) for n in EXCLUDED_LINK_NAMES], before=r"\[\[\s*(", after=r")"
  134. )
  135. # First structured heading: framing is everything before it.
  136. _FIRST_SECTION_RE = re.compile(r"^##\s+(observations|relations)\b", re.IGNORECASE | re.MULTILINE)
  137. # --- The path gate
  138. class PathDenied(Exception):
  139. pass
  140. def _is_denied(rel: str) -> bool:
  141. """True if the lowercased vault-relative path is, or sits under, a denied prefix."""
  142. return any(rel == deny or rel.startswith(deny + "/") for deny in DENY_PREFIXES)
  143. def _resolve_safe(root: Path, rel: str) -> Path:
  144. """Resolve a vault-relative path and fail closed on traversal, symlink or deny."""
  145. rel_norm = rel.replace("\\", "/").lstrip("/")
  146. candidate = (root / rel_norm).resolve()
  147. try:
  148. candidate.relative_to(root)
  149. except ValueError:
  150. raise PathDenied(f"escapes root: {rel!r}")
  151. rel_from_root = candidate.relative_to(root).as_posix().lower()
  152. if _is_denied(rel_from_root):
  153. raise PathDenied(f"denied prefix: {rel!r}")
  154. if set(rel_from_root.split("/")) & set(DENY_COMPONENTS):
  155. raise PathDenied(f"denied component: {rel!r}")
  156. return candidate
  157. def _iter_safe_files(root: Path):
  158. """Walk one vault root, pruning denied dirs, yielding gate-passing notes."""
  159. deny_components = set(DENY_COMPONENTS)
  160. for dirpath, dirnames, filenames in os.walk(root):
  161. here = Path(dirpath)
  162. rel_dir = here.relative_to(root).as_posix().lower()
  163. rel_dir = "" if rel_dir == "." else rel_dir
  164. kept = []
  165. for d in dirnames:
  166. if d.lower() in deny_components:
  167. continue
  168. if _is_denied(f"{rel_dir}/{d}".lstrip("/").lower()):
  169. continue
  170. kept.append(d)
  171. dirnames[:] = kept
  172. for fn in filenames:
  173. fp = here / fn
  174. if fp.suffix.lower() not in STAGE_SUFFIXES:
  175. continue
  176. try:
  177. _resolve_safe(root, fp.relative_to(root).as_posix())
  178. except (PathDenied, ValueError):
  179. continue
  180. yield fp
  181. def _self_check(root: Path) -> None:
  182. """Refuse to run unless every known-private probe is denied."""
  183. for probe in ("_private", "_Private", "Efforts/finance",
  184. "Efforts/Journal/anything.md", ".obsidian",
  185. "Efforts/some-vault/.obsidian/x.md", ".basic-memory"):
  186. try:
  187. _resolve_safe(root, probe)
  188. except PathDenied:
  189. pass
  190. else:
  191. raise SystemExit(f"DENYLIST FAILED to block {probe!r}: refusing to run.")
  192. # --- Section gate: whole note or framing only
  193. def _framing_only(text: str) -> str:
  194. """Return the H1 and framing prose before the first ## Observations/Relations,
  195. capped at 1500 characters when there is no such heading, with excluded-note
  196. wikilinks redacted."""
  197. m = _FIRST_SECTION_RE.search(text)
  198. framing = text[: m.start()] if m else text[:1500]
  199. framing = _EXCLUDED_LINK_RE.sub("[[redacted", framing)
  200. return framing.rstrip() + "\n"
  201. def _gate_content(text: str) -> str:
  202. """Return the note whole, or its framing only if it matches a sensitive pattern
  203. or links to an excluded note."""
  204. mixed = bool(_SENSITIVE_RE.search(text)) or bool(_EXCLUDED_LINK_RE.search(text))
  205. return _framing_only(text) if mixed else text
  206. # --- Scaffolding skip and one explainer per vault
  207. def _vault_key(vault_name: str, rel_posix: str) -> str:
  208. """The explainer bucket for a note: `<vault>/Efforts/<X>` or `<vault>/Atlas/<X>`
  209. for notes inside a nested vault, otherwise the root vault's name."""
  210. parts = rel_posix.split("/")
  211. low = [p.lower() for p in parts]
  212. if len(parts) >= 2 and low[0] in EXPLAINER_PARENT_DIRS:
  213. return f"{vault_name}/{parts[0]}/{parts[1]}"
  214. return vault_name # root-level vault bucket
  215. def _has_path_fragment_skip(rel_posix: str) -> bool:
  216. low = "/" + rel_posix.lower() + "/"
  217. return any(frag in low for frag in SCAFFOLD_PATH_FRAGMENTS)
  218. def _choose_explainers(safe_files):
  219. """Given (vault_name, fp, rel_posix) tuples, pick one explainer per vault bucket
  220. in EXPLAINER_PRIORITY order. Returns the manifest keys to keep even though
  221. they would otherwise be skipped as scaffolding."""
  222. by_vault: dict[str, dict[str, str]] = {}
  223. for vault_name, fp, rel in safe_files:
  224. base = fp.name.lower()
  225. if base in EXPLAINER_PRIORITY:
  226. vk = _vault_key(vault_name, rel)
  227. by_vault.setdefault(vk, {})[base] = f"{vault_name}/{rel}"
  228. keep = set()
  229. for vk, candidates in by_vault.items():
  230. for pref in EXPLAINER_PRIORITY:
  231. if pref in candidates:
  232. keep.add(candidates[pref])
  233. break
  234. return keep
  235. # --- Manifest
  236. def _load_manifest() -> dict:
  237. if MANIFEST_PATH.exists():
  238. try:
  239. return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
  240. except (OSError, json.JSONDecodeError):
  241. return {}
  242. return {}
  243. def _save_manifest(m: dict) -> None:
  244. MANIFEST_PATH.write_text(json.dumps(m, indent=2, sort_keys=True), encoding="utf-8")
  245. def _hash(s: str) -> str:
  246. return hashlib.sha256(s.encode("utf-8")).hexdigest()
  247. # --- Main
  248. def main() -> int:
  249. if not VAULT_ROOTS:
  250. raise SystemExit("Set VAULT_ROOTS (colon-separated absolute vault paths).")
  251. for r in VAULT_ROOTS:
  252. if not r.is_dir():
  253. raise SystemExit(f"Not a directory: {r}")
  254. _self_check(VAULT_ROOTS[0])
  255. # 1. Wipe staging, so the distiller never reads a previous run's leftovers.
  256. if STAGING_DIR.exists():
  257. shutil.rmtree(STAGING_DIR)
  258. STAGING_DIR.mkdir(parents=True, exist_ok=True)
  259. manifest = _load_manifest()
  260. new_manifest: dict = {}
  261. staged = skipped = framed = scaffold_skipped = 0
  262. # Pre-pass: collect all safe files, then resolve the one explainer per vault.
  263. safe_files = []
  264. for root in VAULT_ROOTS:
  265. vault_name = root.name
  266. for fp in _iter_safe_files(root):
  267. rel = fp.relative_to(root).as_posix()
  268. safe_files.append((vault_name, fp, rel))
  269. explainer_keep = _choose_explainers(safe_files)
  270. # 2-3. Walk, apply scaffolding skip, gate, hash, compare, stage new/changed.
  271. for vault_name, fp, rel in safe_files:
  272. key = f"{vault_name}/{rel}"
  273. # Path-fragment skips (templates, run logs) are absolute: never kept, even
  274. # if named like an explainer. Other scaffolding is kept only as the vault's
  275. # chosen explainer or a SCAFFOLD_KEEP_PATHS exception.
  276. if _has_path_fragment_skip(rel):
  277. scaffold_skipped += 1
  278. continue
  279. if _is_scaffold_name(fp.name) and key not in explainer_keep:
  280. rel_low = rel.lower()
  281. if not any(rel_low.endswith(p) for p in SCAFFOLD_KEEP_PATHS):
  282. scaffold_skipped += 1
  283. continue
  284. try:
  285. raw = fp.read_text(encoding="utf-8", errors="ignore")
  286. except OSError:
  287. continue
  288. content = _gate_content(raw)
  289. if not content.strip():
  290. continue
  291. if content != raw:
  292. framed += 1
  293. # Hash what is staged, so a change the gate strips never re-stages a note.
  294. h = _hash(content)
  295. new_manifest[key] = h
  296. if manifest.get(key) == h:
  297. skipped += 1
  298. continue
  299. safe_name = key.replace("/", "__")
  300. (STAGING_DIR / safe_name).write_text(content, encoding="utf-8")
  301. staged += 1
  302. # 4. The manifest holds only this run's notes, so deleted notes drop out of it.
  303. _save_manifest(new_manifest)
  304. print(f"gate: staged={staged} skipped={skipped} framing-only={framed} "
  305. f"scaffold-skipped={scaffold_skipped} total-staged={len(new_manifest)} "
  306. f"-> {STAGING_DIR}")
  307. return 0
  308. if __name__ == "__main__":
  309. raise SystemExit(main())