vault analyze now defaults to Haiku/Bedrock (cheap+fast, the nightly tier) instead of the profile default. --sonnet and --opus step intelligence/cost up for harder judgment calls; raw -m/--provider still override entirely. Provider is forced to bedrock only for the haiku (us.anthropic.*) tier; the global.anthropic.* sonnet/opus IDs use the profile default provider. Adds an alive banner to stderr (headless -z buffers, so a pass is silent 1-3 min).
193 lines
7.5 KiB
Python
Executable File
193 lines
7.5 KiB
Python
Executable File
#!/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 <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()
|