Files
Hermes 069552d1b4 vault move: support area/category/ID codes on either side, incl. cross-level promotion
- vault move now accepts any JD code kind (area/cat/ID) on both old and
  new, not just ID->ID. Enables e.g. 'vault move F.50.09 F.59' to promote
  an ID to a standalone category.
- Refuses if the source has nested JD-coded children (would orphan their
  codes -- that's a bulk renumber, a different and riskier operation).
- Also commits vault_sync_daemon.py (inotify bidirectional /vault<->/data
  sync service, with ntfy notifications on rename/archive/stub/error).
2026-09-15 21:56:27 -04:00

198 lines
7.8 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <query> find IDs/notes by code, title, or content
vault show <ID> show an ID's note + its /data home + blob count
vault create <ID> "<title>" create ID note (folder+folder-note) in /vault AND /data
vault move <oldCode> <newCode> renumber/promote: area, category, or ID code, either side (e.g.
cross-level "vault move F.50.09 F.59" promotes an ID to a
standalone category); rewrites backlinks, moves /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 [--haiku|--sonnet|--opus] [-m MODEL] [--provider PROVIDER]
spawn a Hermes Librarian pass. DEFAULT: Opus (smart —
draws the auto-fix/flag line correctly, ~$0.14/run so cheap
enough for nightly). --sonnet / --haiku step DOWN for
cheaper/faster passes; -m/--provider override entirely.
"""
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 Opus — smart enough to draw the auto-fix/
# flag line correctly, and ~$0.14/run so cheap enough for a nightly pass. --sonnet /
# --haiku step DOWN. IDs verified against this host's Hermes config + 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 Opus (smart — draws the auto-fix/flag line correctly,
# ~$0.14/run so cheap enough to run nightly). --haiku/--sonnet step DOWN.
# Raw -m/--provider override the tier entirely (escape hatch for future models
# or non-Bedrock providers). Tier flags are last-wins if combined.
tier = "opus"
if "--sonnet" in argv:
tier = "sonnet"
if "--haiku" in argv:
tier = "haiku"
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 == "opus":
print(" step DOWN: vault analyze --sonnet (or --haiku) for cheaper/faster passes",
file=sys.stderr)
sys.stderr.flush()
subprocess.run(hcmd)
else:
die(f"unknown command '{cmd}'")
if __name__ == "__main__":
main()