feat(agents-vault): back up curated agent memory to a host-scoped vault repo #126

Merged
rootiest merged 19 commits from feat/agent-memory-vault into main 2026-09-03 23:09:57 +00:00
2 changed files with 273 additions and 0 deletions
Showing only changes of commit 1c9cedb8f3 - Show all commits
+109
View File
@@ -36,6 +36,17 @@
# of megabytes per project, growing per session). Paths are allowlisted, # of megabytes per project, growing per session). Paths are allowlisted,
# never denylisted, so nothing new upstream adds can leak in. # never denylisted, so nothing new upstream adds can leak in.
# #
# Global state that belongs to no project is tracked as well. Claude's
# global memory directory (~/.claude/memory) is symlinked into the vault
# exactly like per-project memory, and is only linked when one side or
# the other already holds something, since that path does not exist by
# default. agy's knowledge store and settings.json are copied rather
# than symlinked: agy partitions by conversation UUID rather than by
# workspace, so it has no per-project slice, and its store sits beside
# SQLite databases whose WAL sidecars must never be live-tracked inside
# a git worktree. A failed copy is reported but is not fatal, because an
# incomplete backup still leaves the agent working.
#
# Because the slug is derived from the remote, gaining, losing, or # Because the slug is derived from the remote, gaining, losing, or
# rewriting a project's origin changes it. Each run detects this by # rewriting a project's origin changes it. Each run detects this by
# reading the previous slug straight off the live memory symlink's # reading the previous slug straight off the live memory symlink's
@@ -72,6 +83,22 @@
# __fish_agent_vault_autopush to 1 to also push on wrapper launch; # __fish_agent_vault_autopush to 1 to also push on wrapper launch;
# it defaults to off so a backgrounded push can never hang or prompt # it defaults to off so a backgrounded push can never hang or prompt
# invisibly underneath a starting agent. # invisibly underneath a starting agent.
#
# Three further variables exist only so the test suite can run against
# throwaway directories instead of the real home, and are not meant for
# everyday use. __fish_agent_vault_claude_root overrides Claude's
# per-project directory (~/.claude/projects), which is where the
# per-project memory directories live. __fish_agent_vault_claude_home
# overrides Claude's home directory (~/.claude), whose memory
# subdirectory holds the global memory. Those two name different paths
# and setting one has no effect on the other.
# __fish_agent_vault_agy_root overrides agy's state directory
# (~/.gemini/antigravity-cli), which is only ever read from.
#
# The last two are not optional niceties. Without them, a test run on a
# machine that has a real global memory directory would move it into a
# throwaway directory and leave a dangling symlink behind, which is
# strictly worse than having had no backup at all.
function agents-vault --description 'track curated agent memory in a host-scoped vault repo' function agents-vault --description 'track curated agent memory in a host-scoped vault repo'
set -l c_head (set_color --bold cyan) set -l c_head (set_color --bold cyan)
set -l c_cmd (set_color --bold) set -l c_cmd (set_color --bold)
@@ -196,6 +223,88 @@ function agents-vault --description 'track curated agent memory in a host-scoped
set changed 1 set changed 1
end end
# ────────────────────────── global state ───────────────────────────
# Allowlist, never a denylist. The agy root and ~/.claude also hold
# .credentials.json, history.jsonl, sessions/, session-env/,
# shell-snapshots/, and the conversation databases, so only the paths
# named here are ever copied or linked; a "back up all but known junk"
# rule would leak secrets the first time upstream adds a file.
set -l agy_root $__fish_agent_vault_agy_root
test -n "$agy_root"; or set agy_root "$HOME/.gemini/antigravity-cli"
# agy state is copied, never symlinked: agy partitions by conversation
# UUID rather than by workspace, so there is no per-project slice to
# link, and its store sits beside SQLite databases whose WAL sidecars
# must never be live-tracked inside a git worktree.
#
# A failed copy is reported but not fatal. An incomplete backup still
# leaves the agent fully working, unlike a broken memory symlink, and
# this runs on every agent launch.
set -l agy_copied 0
if test -d "$agy_root/knowledge"
if not mkdir -p "$vault/global/agy/knowledge"
echo "$c_err""agents-vault: could not create $vault/global/agy/knowledge$c_reset" >&2
else if not command cp -r "$agy_root/knowledge/." "$vault/global/agy/knowledge/"
echo "$c_warn""agents-vault: could not copy the agy knowledge store$c_reset" >&2
else
set agy_copied 1
end
end
if test -f "$agy_root/settings.json"
if not mkdir -p "$vault/global/agy"
echo "$c_err""agents-vault: could not create $vault/global/agy$c_reset" >&2
else if not command cp "$agy_root/settings.json" "$vault/global/agy/settings.json"
echo "$c_warn""agents-vault: could not copy the agy settings file$c_reset" >&2
else
set agy_copied 1
end
end
# cp cannot report whether anything actually differed, so treating the
# copy itself as a change would set $changed on every single run --
# and agents-vault runs on every claude/agy launch, so --quiet would
# print a summary line every time and stop meaning anything. Ask git
# instead: the copy counts only when it left global/agy/ dirty.
if test $agy_copied -eq 1
set -l agy_dirty (git -C "$vault" status --porcelain -- global/agy 2>/dev/null)
if test -n "$agy_dirty"
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Copied agy global state into the vault$c_reset"
end
end
# Claude's global memory directory is symlinked into the vault exactly
# like per-project memory, so backup and restore stay one operation.
set -l claude_home $__fish_agent_vault_claude_home
test -n "$claude_home"; or set claude_home "$HOME/.claude"
set -l glive "$claude_home/memory"
set -l gvault "$vault/global/claude/memory"
set -l gvault_content
test -d "$gvault"; and set gvault_content (command ls -A "$gvault" 2>/dev/null)
# Link when either side already has something: the live directory
# exists (back it up) or a cloned vault carries global memory (restore
# it). Never out of thin air -- ~/.claude/memory does not exist by
# default, and fabricating it would invent state Claude never asked
# for and permanently claim the path.
if test -d "$glive"; or test -L "$glive"; or test (count $gvault_content) -gt 0
if not mkdir -p "$gvault"
echo "$c_err""agents-vault: could not create $gvault$c_reset" >&2
return 1
end
set -l gmsg (_agents_repo_ensure_symlink "$glive" "$gvault")
set -l grc $status
if test $grc -ne 0
echo "$c_err""agents-vault: could not link $glive$c_reset" >&2
return 1
end
if test -n "$gmsg"
set changed 1
test $verbose -eq 1; and echo "$c_ok$gmsg$c_reset"
end
end
# ─────────────────────── unimplemented modes ─────────────────────── # ─────────────────────── unimplemented modes ───────────────────────
for f in _flag_push _flag_restore _flag_status _flag_adopt _flag_remote for f in _flag_push _flag_restore _flag_status _flag_adopt _flag_remote
if set -q $f if set -q $f
+164
View File
@@ -47,6 +47,41 @@ function cleanup
end end
end end
# Describe a path well enough to prove it was not disturbed: a symlink is
# recorded by its literal target (path resolve would hide a link that was
# repointed into a since-deleted temp directory), everything else by kind.
function snapshot_path --argument-names p
if test -L "$p"
printf 'link:%s\n' (readlink "$p")
else if test -d "$p"
printf 'dir\n'
else if test -e "$p"
printf 'file\n'
else
printf 'absent\n'
end
end
# ────────────────────────── hermeticity floor ──────────────────────────
# agents-vault reads and *writes* global agent state under ~/.claude and
# ~/.gemini when it is not told otherwise, so every run in this file is
# pointed at a throwaway home first. Without this, a test run would copy
# the real agy knowledge store into a temp vault and -- far worse -- move a
# real ~/.claude/memory into a temp directory that cleanup then deletes,
# leaving a dangling symlink behind. Individual sections override these
# with their own fixtures and must restore them here, not erase them.
set -g HERMETIC_HOME (mktemp -d)
set -ga TMPDIRS $HERMETIC_HOME
mkdir -p $HERMETIC_HOME/claude $HERMETIC_HOME/agy
set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude
set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
# Recorded before anything runs, asserted at the very end.
set -g REAL_CLAUDE_MEMORY "$HOME/.claude/memory"
set -g REAL_AGY_ROOT "$HOME/.gemini/antigravity-cli"
set -g REAL_CLAUDE_MEMORY_BEFORE (snapshot_path "$REAL_CLAUDE_MEMORY")
set -g REAL_AGY_ROOT_BEFORE (snapshot_path "$REAL_AGY_ROOT")
# ─────────────────────────── slug derivation ─────────────────────────── # ─────────────────────────── slug derivation ───────────────────────────
echo "== _agents_repo_slug ==" echo "== _agents_repo_slug =="
@@ -470,6 +505,135 @@ check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$
set -e __fish_agent_vault_dir set -e __fish_agent_vault_dir
set -e __fish_agent_vault_claude_root set -e __fish_agent_vault_claude_root
# ──────────────────────────── global state ─────────────────────────────
# State that belongs to no project: agy's knowledge store and settings.json
# (copied, because agy keys by conversation UUID and its store sits beside
# SQLite databases with WAL sidecars) and Claude's *global* memory
# directory (symlinked, exactly like per-project memory).
echo ""
echo "== agents-vault (global state) =="
set -l vroot5 (mktemp -d); set -ga TMPDIRS $vroot5
set -l croot5 (mktemp -d); set -ga TMPDIRS $croot5
set -l chome5 (mktemp -d); set -ga TMPDIRS $chome5
set -l agy5 (mktemp -d); set -ga TMPDIRS $agy5
set -g __fish_agent_vault_dir $vroot5/agent-vault
set -g __fish_agent_vault_claude_root $croot5
set -g __fish_agent_vault_claude_home $chome5
set -g __fish_agent_vault_agy_root $agy5
mkdir -p $agy5/knowledge $agy5/conversations
echo learned >$agy5/knowledge/fact.md
echo '{"model":"x"}' >$agy5/settings.json
# Decoys that must never be copied: the allowlist names knowledge/ and
# settings.json and nothing else.
echo secret >$agy5/history.jsonl
: >$agy5/conversations/c.db-wal
# A global (non-per-project) Claude memory directory with a sentinel file.
# __fish_agent_vault_claude_home is what keeps this off the real ~/.claude:
# if agents-vault ignored the override, these checks would fail here *and*
# the real global memory would be moved into $chome5.
mkdir -p $chome5/memory
echo global-memory >$chome5/memory/g.md
set -l gp (new_repo https://git.rootiest.dev/rootiest/globals.git)
pushd $gp >/dev/null
agents-vault --silent
popd >/dev/null
check "agy knowledge copied" learned (cat $vroot5/agent-vault/global/agy/knowledge/fact.md)
check "agy settings copied" '{"model":"x"}' (cat $vroot5/agent-vault/global/agy/settings.json)
check "agy knowledge is a copy not a link" false (test -L $vroot5/agent-vault/global/agy/knowledge; and echo true; or echo false)
check "history.jsonl not copied" false (test -e $vroot5/agent-vault/global/agy/history.jsonl; and echo true; or echo false)
check "conversations not copied" false (test -e $vroot5/agent-vault/global/agy/conversations; and echo true; or echo false)
check "global claude memory in the vault" global-memory (cat $vroot5/agent-vault/global/claude/memory/g.md)
check "global claude memory is now a link" true (test -L $chome5/memory; and echo true; or echo false)
check "global link points into the vault" (path resolve $vroot5/agent-vault/global/claude/memory) (path resolve $chome5/memory)
check "global memory readable through the link" global-memory (cat $chome5/memory/g.md)
# --quiet must stay silent when nothing upstream changed. agents-vault runs
# on every claude/agy launch, so a copy step that reported "changed" on
# every run (cp cannot tell whether anything differed) would print a
# summary line at every launch and defeat the flag entirely.
pushd $gp >/dev/null
set -l q1 (agents-vault --quiet)
set -l q2 (agents-vault --quiet)
popd >/dev/null
check "first --quiet rerun prints nothing" "" "$q1"
check "second --quiet rerun prints nothing" "" "$q2"
# ... but a genuine upstream change must still re-sync and still report.
echo "learned more" >$agy5/knowledge/fact.md
pushd $gp >/dev/null
set -l q3 (agents-vault --quiet)
popd >/dev/null
check "agy knowledge re-synced" "learned more" (cat $vroot5/agent-vault/global/agy/knowledge/fact.md)
check "changed agy content reports in --quiet" true (string match -q '*Synced*' -- "$q3"; and echo true; or echo false)
set -e __fish_agent_vault_dir
set -e __fish_agent_vault_claude_root
# ────────────────── emergent restore of global memory ──────────────────
# The global counterpart of the per-project restore case: a cloned vault
# already carries global/claude/memory but the live ~/.claude/memory does
# not exist yet. The link must still be created, or a starting agent writes
# fresh, history-less global memory beside the restored copy.
echo ""
echo "== agents-vault (global emergent restore) =="
set -l vroot6 (mktemp -d); set -ga TMPDIRS $vroot6
set -l croot6 (mktemp -d); set -ga TMPDIRS $croot6
set -l chome6 (mktemp -d); set -ga TMPDIRS $chome6
set -l agy6 (mktemp -d); set -ga TMPDIRS $agy6
set -g __fish_agent_vault_dir $vroot6/agent-vault
set -g __fish_agent_vault_claude_root $croot6
set -g __fish_agent_vault_claude_home $chome6
set -g __fish_agent_vault_agy_root $agy6
mkdir -p $vroot6/agent-vault/global/claude/memory
echo restored-global >$vroot6/agent-vault/global/claude/memory/old.md
set -l gp6 (new_repo https://git.rootiest.dev/rootiest/globals-restore.git)
pushd $gp6 >/dev/null
agents-vault --silent
popd >/dev/null
check "global restore: link created with no prior live dir" true (test -L $chome6/memory; and echo true; or echo false)
check "global restore: vault content readable through the link" restored-global (cat $chome6/memory/old.md 2>/dev/null)
# A home with neither side populated must not have a memory/ invented for
# it: ~/.claude/memory does not exist by default.
set -l chome7 (mktemp -d); set -ga TMPDIRS $chome7
set -l vroot7 (mktemp -d); set -ga TMPDIRS $vroot7
set -g __fish_agent_vault_dir $vroot7/agent-vault
set -g __fish_agent_vault_claude_home $chome7
set -l gp7 (new_repo https://git.rootiest.dev/rootiest/globals-absent.git)
pushd $gp7 >/dev/null
agents-vault --silent
popd >/dev/null
check "absent global memory is not fabricated" false (test -e $chome7/memory; and echo true; or echo false)
set -e __fish_agent_vault_dir
set -e __fish_agent_vault_claude_root
set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude
set -g __fish_agent_vault_agy_root $HERMETIC_HOME/agy
# ──────────────────────── hermeticity assertion ────────────────────────
# The whole suite must never have touched the real global agent state. The
# failure this guards is specific: a global-memory sync with no test
# override would move ~/.claude/memory into a mktemp vault that cleanup
# then deletes, leaving the live path a dangling symlink.
echo ""
echo "== hermeticity =="
check "real ~/.claude/memory untouched" "$REAL_CLAUDE_MEMORY_BEFORE" (snapshot_path "$REAL_CLAUDE_MEMORY")
check "real agy root untouched" "$REAL_AGY_ROOT_BEFORE" (snapshot_path "$REAL_AGY_ROOT")
set -e __fish_agent_vault_claude_home
set -e __fish_agent_vault_agy_root
cleanup cleanup
echo "" echo ""
echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed"