feat(agents-vault): back up curated agent memory to a host-scoped vault repo #126
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user