feat: claude-memory-sync — back up Claude Code auto-memory to its own private repo
Auto-memory (~/.claude/projects/*/memory, 774 files across 49 projects) goes to
gitea vh/claude-memory, not this repo. The git dir is ~/claude-memory.git and its
work tree is ~/.claude/projects, and exclude rules admit only memory/ folders, so
transcripts stay local and nothing is added inside ~/.claude.
- home_root/.local/bin/claude-memory-sync: commit, rebase onto origin, push, under
a flock; --status; --init for a fresh machine. Every run records its result
for the drift check.
- home_config/systemd/user/claude-memory-sync.{service,timer}: hourly with
Persistent=true. Linked but not yet enabled.
- link-dotfiles --check now also reports uncommitted changes (a stale session
can write old content back through an intact link) and a failed or stale
(>3h) memory sync.
- CLAUDE.md and README document the backup and the never-put-credentials-in-memory rule.
This commit is contained in:
@@ -66,6 +66,26 @@ write through a link and names the target, which is the safe behaviour. Claude
|
|||||||
Code may also rewrite `settings.json` itself when settings change from inside the
|
Code may also rewrite `settings.json` itself when settings change from inside the
|
||||||
app. After that, or whenever in doubt, run `./link-dotfiles --check`.
|
app. After that, or whenever in doubt, run `./link-dotfiles --check`.
|
||||||
|
|
||||||
|
## Claude Code auto-memory (separate private repo)
|
||||||
|
|
||||||
|
Auto-memory (`~/.claude/projects/*/memory`) is backed up to gitea
|
||||||
|
`vh/claude-memory`, not to this repo, because it changes constantly and holds
|
||||||
|
operational detail. `home_root/.local/bin/claude-memory-sync` does the work. Its
|
||||||
|
git dir is `~/claude-memory.git` and its work tree is `~/.claude/projects`, and
|
||||||
|
exclude rules admit only the `memory/` folders, so transcripts never leave the
|
||||||
|
machine and nothing extra appears inside `~/.claude`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude-memory-sync # commit, rebase onto the remote, push
|
||||||
|
claude-memory-sync --status # last run + uncommitted count
|
||||||
|
claude-memory-sync --init # fresh machine: clone and wire up
|
||||||
|
systemctl --user enable --now claude-memory-sync.timer # hourly (unit files in home_config/systemd/user)
|
||||||
|
```
|
||||||
|
|
||||||
|
Project folders are named after absolute paths, so memory lines up across Linux
|
||||||
|
boxes; macOS (`/Users/...`) gets its own folders. `./link-dotfiles --check` flags a
|
||||||
|
failed or stale (>3h) sync.
|
||||||
|
|
||||||
## Windows (PowerShell)
|
## Windows (PowerShell)
|
||||||
|
|
||||||
`stow` doesn't run natively on Windows, so use the PowerShell equivalent. It
|
`stow` doesn't run natively on Windows, so use the PowerShell equivalent. It
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Back up Claude Code auto-memory to gitea vh/claude-memory
|
||||||
|
Documentation=file://%h/.local/bin/claude-memory-sync
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=%h/.local/bin/claude-memory-sync
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Hourly Claude Code auto-memory backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
RandomizedDelaySec=5min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -16,7 +16,16 @@ applies to every other linked file: in `~/.claude` that is
|
|||||||
`settings.json`, `statusline-command.sh`, `CONTEXT.md`,
|
`settings.json`, `statusline-command.sh`, `CONTEXT.md`,
|
||||||
`keybindings.json`, `bin/ratecheck` and `skills/australis-design`, plus
|
`keybindings.json`, `bin/ratecheck` and `skills/australis-design`, plus
|
||||||
`~/.zshrc`, `~/.zshenv` and `~/.gitconfig`. `~/dotfiles/link-dotfiles
|
`~/.zshrc`, `~/.zshenv` and `~/.gitconfig`. `~/dotfiles/link-dotfiles
|
||||||
--check` reports any file that has come loose, and any dangling link.
|
--check` reports any file that has come loose, any dangling link, any
|
||||||
|
uncommitted change, and a stale auto-memory backup. A long-running
|
||||||
|
session can write its stale copy of `settings.json` back through the link,
|
||||||
|
so re-check after editing user settings.
|
||||||
|
|
||||||
|
**Auto-memory** (`~/.claude/projects/*/memory`) is backed up hourly to
|
||||||
|
the private gitea repo `vh/claude-memory` by `claude-memory-sync` (git
|
||||||
|
dir `~/claude-memory.git`; `--status` shows the last run). Transcripts
|
||||||
|
stay local. **Never write a credential into a memory file:** memory is
|
||||||
|
copied off-box.
|
||||||
|
|
||||||
## Operator identity
|
## Operator identity
|
||||||
|
|
||||||
|
|||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Back up Claude Code auto-memory (~/.claude/projects/*/memory) to its own private
|
||||||
|
# repo, gitea vh/claude-memory. The git dir lives at ~/claude-memory.git with
|
||||||
|
# ~/.claude/projects as its work tree, so nothing is added inside ~/.claude and
|
||||||
|
# Claude Code keeps writing memory exactly as before. The exclude rules below
|
||||||
|
# admit only the memory/ folders; transcripts never leave the machine.
|
||||||
|
#
|
||||||
|
# claude-memory-sync commit local changes, rebase onto the remote, push
|
||||||
|
# claude-memory-sync --status last result, and what is waiting to be committed
|
||||||
|
# claude-memory-sync --init set up this machine from the remote (fresh box)
|
||||||
|
#
|
||||||
|
# Run hourly by the claude-memory-sync.timer systemd user unit. The result of every
|
||||||
|
# run goes to $state; `link-dotfiles --check` reports it when it is stale or failed.
|
||||||
|
#
|
||||||
|
# Project folders are named after absolute paths (-home-lkraven-development-X), so
|
||||||
|
# memory lines up across Linux boxes; macOS (/Users/...) gets its own folders.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$HOME/claude-memory.git"
|
||||||
|
worktree="$HOME/.claude/projects"
|
||||||
|
remote="git@gitea.phasefinal.com:vh/claude-memory.git"
|
||||||
|
state="$HOME/.claude/state/claude-memory-sync.last"
|
||||||
|
g() { git --git-dir="$repo" "$@"; }
|
||||||
|
|
||||||
|
write_exclude() {
|
||||||
|
cat > "$repo/info/exclude" <<'EOF'
|
||||||
|
# Work tree is ~/.claude/projects. Track ONLY each project's memory/ folder;
|
||||||
|
# transcripts (*.jsonl), tool results and everything else stay local.
|
||||||
|
# Written by claude-memory-sync; edit the script, not this file.
|
||||||
|
/*
|
||||||
|
!/*/
|
||||||
|
/*/*
|
||||||
|
!/*/memory/
|
||||||
|
.pytest_cache/
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
record() { # $1 = ok|fail, $2 = detail
|
||||||
|
mkdir -p "$(dirname "$state")"
|
||||||
|
printf '%s %s %s\n' "$(date +%s)" "$1" "$2" > "$state.$$" && mv -f "$state.$$" "$state"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--status)
|
||||||
|
if [ -f "$state" ]; then
|
||||||
|
read -r ts result detail < "$state"
|
||||||
|
when="$(date -d "@$ts" '+%Y-%m-%d %H%M' 2>/dev/null || date -r "$ts" '+%Y-%m-%d %H%M')"
|
||||||
|
echo "last run: $when — $result${detail:+ ($detail)}"
|
||||||
|
else
|
||||||
|
echo "last run: never"
|
||||||
|
fi
|
||||||
|
[ -d "$repo" ] && echo "uncommitted memory changes: $(g status --porcelain | wc -l | tr -d ' ')"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--init)
|
||||||
|
[ -e "$repo" ] && { echo "already set up: $repo" >&2; exit 1; }
|
||||||
|
git clone -q --bare "$remote" "$repo"
|
||||||
|
g config core.bare false
|
||||||
|
g config core.worktree "$worktree"
|
||||||
|
g config status.showUntrackedFiles all
|
||||||
|
g config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
|
||||||
|
g fetch -q origin
|
||||||
|
write_exclude
|
||||||
|
mkdir -p "$worktree"
|
||||||
|
# Index = remote HEAD, work tree untouched: memory already on this box wins on
|
||||||
|
# the first sync, and files only the remote has are restored below.
|
||||||
|
g reset -q
|
||||||
|
g ls-files --deleted -z | xargs -0 -r git --git-dir="$repo" checkout --
|
||||||
|
echo "set up. Local differences (if any) will be committed on the first sync:"
|
||||||
|
g status --short | head -20
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"") ;;
|
||||||
|
*) echo "usage: claude-memory-sync [--status|--init]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ -d "$repo" ] || { echo "no memory repo at $repo (run --init)" >&2; exit 2; }
|
||||||
|
write_exclude
|
||||||
|
|
||||||
|
# One run at a time: a slow push must not overlap the next timer tick.
|
||||||
|
exec 9>"$repo/sync.lock"
|
||||||
|
flock -n 9 || { echo "another sync is running" >&2; exit 0; }
|
||||||
|
|
||||||
|
g add -A
|
||||||
|
if ! g diff --cached --quiet; then
|
||||||
|
n="$(g diff --cached --name-only | wc -l | tr -d ' ')"
|
||||||
|
g commit -q -m "memory: sync from $(hostname -s) ($n file(s))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! g fetch -q origin 2>/dev/null; then
|
||||||
|
record fail "fetch failed — remote unreachable or repo missing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if g rev-parse -q --verify origin/main >/dev/null; then
|
||||||
|
if ! g rebase -q origin/main 2>/dev/null; then
|
||||||
|
g rebase --abort 2>/dev/null || true
|
||||||
|
record fail "rebase conflict with origin/main — resolve by hand"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! g push -q origin main 2>/dev/null; then
|
||||||
|
record fail "push failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
record ok "$(g rev-parse --short HEAD)"
|
||||||
+23
-1
@@ -41,7 +41,29 @@ if [ "${1:-}" = "--check" ]; then
|
|||||||
echo "✗ links into dotfiles whose target is gone:"
|
echo "✗ links into dotfiles whose target is gone:"
|
||||||
printf '%s\n' "$dangling" | sed 's/^/ /'
|
printf '%s\n' "$dangling" | sed 's/^/ /'
|
||||||
fi
|
fi
|
||||||
[ "$drift" -eq 0 ] && echo "✓ no drift: every tracked file is linked, and no dotfiles link dangles."
|
# A link can stay intact while its content moves: an app that holds a stale copy
|
||||||
|
# in memory (a long-running Claude Code session with settings.json) writes it
|
||||||
|
# straight back through the link. That shows up only as an uncommitted change.
|
||||||
|
dirty="$(git -C "$repo" status --porcelain -- home_root home_config 2>/dev/null || true)"
|
||||||
|
if [ -n "$dirty" ]; then
|
||||||
|
drift=1
|
||||||
|
echo "✗ live config differs from the last commit (not backed up yet):"
|
||||||
|
printf '%s\n' "$dirty" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
# Auto-memory backup (claude-memory-sync), checked only on machines that have it.
|
||||||
|
if [ -d "$HOME/claude-memory.git" ]; then
|
||||||
|
last="$HOME/.claude/state/claude-memory-sync.last"
|
||||||
|
if [ ! -f "$last" ]; then
|
||||||
|
drift=1; echo "✗ claude-memory-sync has never completed a run on this machine"
|
||||||
|
else
|
||||||
|
read -r ts result detail < "$last"
|
||||||
|
age=$(( ($(date +%s) - ts) / 60 ))
|
||||||
|
if [ "$result" != ok ] || [ "$age" -gt 180 ]; then
|
||||||
|
drift=1; echo "✗ claude-memory-sync: last run ${age} min ago — $result${detail:+ ($detail)}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ "$drift" -eq 0 ] && echo "✓ no drift: every tracked file is linked and committed, no dotfiles link dangles, auto-memory backup is current."
|
||||||
exit "$drift"
|
exit "$drift"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user