#!/usr/bin/env python3 """ vault — CLI for Ryan's R/J/F/I Obsidian vault. Clobber-safe. Dry-run by default; pass --execute to act. Operates on BOTH trees (/vault map + /data territory) and the topology manifest. Subcommands: vault tree [SYS] print topology (optionally one system) vault search find IDs/notes by code, title, or content vault show show an ID's note + its /data home + blob count vault create "" create ID note (folder+folder-note) in /vault AND /data vault move <oldID> <newID> renumber: rename note+folder, rewrite backlinks, move /data dir vault rename <ID> "<title>" retitle in place, rewrite backlinks vault sync-data reconcile /data skeleton to match manifest (create missing dirs) vault manifest regenerate the topology manifest note vault report [--pretty] deterministic Librarian report (delegates to vault-librarian.py) vault analyze [--sonnet|--opus] [-m MODEL] [--provider PROVIDER] spawn a Hermes Librarian pass. DEFAULT: Haiku/Bedrock (cheap+fast — the nightly/weekly tier). --sonnet / --opus step up intelligence & cost for harder judgment calls. -m/--provider override entirely (future/non-Bedrock models). """ import os, re, sys, subprocess # Resolve sibling modules whether running from /var/lib/hermes, the repo, or the Nix store. _SELF_DIR = os.path.dirname(os.path.realpath(__file__)) sys.path.insert(0, _SELF_DIR) import vault_lib as vl HERMES = "/run/current-system/sw/bin/hermes" LIBRARIAN = os.path.join(_SELF_DIR, "vault-librarian.py") PROMPT = os.path.join(_SELF_DIR, "vault-librarian-prompt.md") # `vault analyze` model tiers. Default is Haiku — cheap+fast for the nightly/weekly # pass. --sonnet / --opus step up intelligence & cost inline. IDs verified against # this host's Hermes config + the active Bedrock provisioning. ANALYZE_TIERS = { "haiku": "us.anthropic.claude-haiku-4-5-20251001-v1:0", "sonnet": "global.anthropic.claude-sonnet-5", "opus": "global.anthropic.claude-opus-4-8", } # Only the haiku (us.anthropic.*) tier is known to route via an explicit `bedrock` # provider. The global.anthropic.* IDs use the profile's default provider unless the # caller passes --provider, to avoid a provider/model-registration mismatch. ANALYZE_TIER_PROVIDER = {"haiku": "bedrock"} WIKILINK_RE = re.compile(r'(!?\[\[)([^\]#|]+)((?:#[^\]|]+)?(?:\|[^\]]+)?)(\]\])') def die(msg, code=1): print("ERROR: " + msg, file=sys.stderr) sys.exit(code) def executing(argv): return "--execute" in argv def sys_root(sys_letter): """Return abs /vault path of a system root, e.g. 'R' -> /vault/R Ryan.""" s = vl.walk_structure(vl.VAULT, include_meta=True)["systems"] if sys_letter not in s: die(f"unknown system '{sys_letter}' (have {sorted(s)})") return os.path.join(vl.VAULT, s[sys_letter]) def find_id_folder(root, id_code): """Return abs folder path for an ID code under root, or None.""" for dp, dns, fns in os.walk(root): dns[:] = [d for d in dns if d not in vl.SKIP_DIRS] if vl.id_code(os.path.basename(dp)) == id_code: return dp return None # ---------- read-only commands ---------- def cmd_tree(argv): s = vl.walk_structure(vl.VAULT, include_meta=True) want = argv[0] if argv else None for code in sorted(s["cats"]): if want and not code.startswith(want): continue n = sum(1 for i in s["ids"] if i.startswith(code + ".")) print(f" {code:8} {os.path.basename(s['cats'][code])} ({n} IDs)") def cmd_search(argv): if not argv: die("usage: vault search <query>") q = " ".join(argv).lower() hits = [] for p in vl.all_notes(vl.VAULT): rel = os.path.relpath(p, vl.VAULT) stem = os.path.splitext(os.path.basename(p))[0] if q in stem.lower() or q in rel.lower(): hits.append((rel, "name")) continue try: if q in open(p, encoding="utf-8").read().lower(): hits.append((rel, "content")) except Exception: pass for rel, how in hits[:50]: print(f" [{how}] {rel}") print(f"\n{len(hits)} match(es).") def cmd_show(argv): if not argv: die("usage: vault show <ID>") code = argv[0] root = sys_root(code[0]) folder = find_id_folder(root, code) if not folder: die(f"ID {code} not found in /vault") print("VAULT folder: " + os.path.relpath(folder, vl.VAULT)) note = os.path.join(folder, os.path.basename(folder) + ".md") print("Folder-note: " + ("present" if os.path.exists(note) else "MISSING")) # /data home drel = os.path.relpath(folder, vl.VAULT) dpath = os.path.join(vl.DATA, drel) if os.path.isdir(dpath): nblobs = sum(len(fs) for _, _, fs in os.walk(dpath)) print(f"/data home: {drel} ({nblobs} blobs)") else: print(f"/data home: MISSING ({drel})") def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(0) cmd, argv = sys.argv[1], sys.argv[2:] dispatch = { "tree": cmd_tree, "search": cmd_search, "show": cmd_show, } # write-commands live in vault_ops (imported lazily to keep this file readable) if cmd in dispatch: dispatch[cmd](argv) elif cmd in ("create", "move", "rename", "sync-data", "manifest"): import vault_ops vault_ops.run(cmd, argv, executing(sys.argv)) elif cmd == "report": sys.exit(subprocess.run([sys.executable, LIBRARIAN] + argv).returncode) elif cmd == "analyze": prompt = open(PROMPT, encoding="utf-8").read() # Model tier: default Haiku (cheap nightly). --sonnet/--opus step up. # Raw -m/--provider override the tier entirely (escape hatch for future models # or non-Bedrock providers). Tier flags are last-wins if combined. tier = "haiku" if "--sonnet" in argv: tier = "sonnet" if "--opus" in argv: tier = "opus" model = ANALYZE_TIERS[tier] provider = ANALYZE_TIER_PROVIDER.get(tier) # None for sonnet/opus (use profile default) if "-m" in argv: i = argv.index("-m") if i + 1 >= len(argv): die("-m requires a model name") model = argv[i + 1] tier = "custom" provider = None # custom model: don't force a provider unless asked if "--provider" in argv: i = argv.index("--provider") if i + 1 >= len(argv): die("--provider requires a provider name") provider = argv[i + 1] hcmd = [HERMES, "-z", prompt, "-m", model] if provider: hcmd += ["--provider", provider] # Banner: headless oneshot buffers output, so show it's alive + what it costs. print(f"→ spawning Hermes Librarian pass [tier={tier} provider={provider or 'profile-default'}]", file=sys.stderr) print(f" model: {model}", file=sys.stderr) print(" output is buffered; a full pass can take 1–3 min before you see anything.", file=sys.stderr) if tier == "haiku": print(" step up: vault analyze --sonnet (or --opus) for harder judgment calls", file=sys.stderr) sys.stderr.flush() subprocess.run(hcmd) else: die(f"unknown command '{cmd}'") if __name__ == "__main__": main()