memgit 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {memgit-0.8.0 → memgit-0.9.0}/PKG-INFO +27 -3
  2. {memgit-0.8.0 → memgit-0.9.0}/README.md +26 -2
  3. {memgit-0.8.0 → memgit-0.9.0}/memgit/__init__.py +1 -1
  4. memgit-0.9.0/memgit/backup.py +377 -0
  5. {memgit-0.8.0 → memgit-0.9.0}/memgit/cli.py +118 -0
  6. {memgit-0.8.0 → memgit-0.9.0}/memgit/delivery.py +32 -0
  7. {memgit-0.8.0 → memgit-0.9.0}/memgit/repo.py +9 -0
  8. {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/PKG-INFO +27 -3
  9. {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/SOURCES.txt +2 -0
  10. {memgit-0.8.0 → memgit-0.9.0}/pyproject.toml +1 -1
  11. memgit-0.9.0/tests/test_backup.py +236 -0
  12. {memgit-0.8.0 → memgit-0.9.0}/tests/test_v060.py +33 -0
  13. {memgit-0.8.0 → memgit-0.9.0}/tests/test_v081.py +26 -0
  14. {memgit-0.8.0 → memgit-0.9.0}/LICENSE +0 -0
  15. {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/__init__.py +0 -0
  16. {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/client.py +0 -0
  17. {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/commands.py +0 -0
  18. {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/crypto.py +0 -0
  19. {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/state.py +0 -0
  20. {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/sync.py +0 -0
  21. {memgit-0.8.0 → memgit-0.9.0}/memgit/evaluate.py +0 -0
  22. {memgit-0.8.0 → memgit-0.9.0}/memgit/gitdigest.py +0 -0
  23. {memgit-0.8.0 → memgit-0.9.0}/memgit/graph.py +0 -0
  24. {memgit-0.8.0 → memgit-0.9.0}/memgit/hooks.py +0 -0
  25. {memgit-0.8.0 → memgit-0.9.0}/memgit/http_server.py +0 -0
  26. {memgit-0.8.0 → memgit-0.9.0}/memgit/importer.py +0 -0
  27. {memgit-0.8.0 → memgit-0.9.0}/memgit/links.py +0 -0
  28. {memgit-0.8.0 → memgit-0.9.0}/memgit/mcp_server.py +0 -0
  29. {memgit-0.8.0 → memgit-0.9.0}/memgit/metrics.py +0 -0
  30. {memgit-0.8.0 → memgit-0.9.0}/memgit/models.py +0 -0
  31. {memgit-0.8.0 → memgit-0.9.0}/memgit/project.py +0 -0
  32. {memgit-0.8.0 → memgit-0.9.0}/memgit/sanitize.py +0 -0
  33. {memgit-0.8.0 → memgit-0.9.0}/memgit/scorer.py +0 -0
  34. {memgit-0.8.0 → memgit-0.9.0}/memgit/store.py +0 -0
  35. {memgit-0.8.0 → memgit-0.9.0}/memgit/tokens.py +0 -0
  36. {memgit-0.8.0 → memgit-0.9.0}/memgit/toon.py +0 -0
  37. {memgit-0.8.0 → memgit-0.9.0}/memgit/usage.py +0 -0
  38. {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/dependency_links.txt +0 -0
  39. {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/entry_points.txt +0 -0
  40. {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/requires.txt +0 -0
  41. {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/top_level.txt +0 -0
  42. {memgit-0.8.0 → memgit-0.9.0}/setup.cfg +0 -0
  43. {memgit-0.8.0 → memgit-0.9.0}/tests/test_accrue.py +0 -0
  44. {memgit-0.8.0 → memgit-0.9.0}/tests/test_advanced.py +0 -0
  45. {memgit-0.8.0 → memgit-0.9.0}/tests/test_aliases.py +0 -0
  46. {memgit-0.8.0 → memgit-0.9.0}/tests/test_core.py +0 -0
  47. {memgit-0.8.0 → memgit-0.9.0}/tests/test_delivery.py +0 -0
  48. {memgit-0.8.0 → memgit-0.9.0}/tests/test_setup.py +0 -0
  49. {memgit-0.8.0 → memgit-0.9.0}/tests/test_store_repo.py +0 -0
  50. {memgit-0.8.0 → memgit-0.9.0}/tests/test_toon.py +0 -0
  51. {memgit-0.8.0 → memgit-0.9.0}/tests/test_v020.py +0 -0
  52. {memgit-0.8.0 → memgit-0.9.0}/tests/test_v030.py +0 -0
  53. {memgit-0.8.0 → memgit-0.9.0}/tests/test_v040.py +0 -0
  54. {memgit-0.8.0 → memgit-0.9.0}/tests/test_v070.py +0 -0
  55. {memgit-0.8.0 → memgit-0.9.0}/tests/test_v080.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://memgit.dev
@@ -44,7 +44,7 @@ Version-controlled, cross-AI context that persists, diffs, rolls back, and syncs
44
44
 
45
45
  [![PyPI](https://img.shields.io/pypi/v/memgit)](https://pypi.org/project/memgit/)
46
46
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
47
- [![Tests](https://img.shields.io/badge/tests-404%20passing-brightgreen)](tests/)
47
+ [![Tests](https://img.shields.io/badge/tests-431%20passing-brightgreen)](tests/)
48
48
 
49
49
  ---
50
50
 
@@ -296,6 +296,23 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
296
296
 
297
297
  ---
298
298
 
299
+ ## Backups that actually happen
300
+
301
+ memgit's premise is that the AI is the operator — but backup used to require a human to remember `memgit git init --remote <url>` and keep pushing. On this project's own store that meant 1,734 memories on one disk with no copy anywhere, five weeks in. A maintenance task that needs a human command is a task that will not happen.
302
+
303
+ Since 0.9.0 it runs itself, at the end of a session, with the safety boundary drawn at **network egress rather than effort**:
304
+
305
+ - **Local destinations are automatic** — a cloud-synced folder you already have (iCloud, Dropbox, Google Drive, OneDrive) or an external volume. memgit copies files; it opens no connection and signs up for no service. Your existing sync client does the rest.
306
+ - **A git remote is pushed to only if you already configured one.** memgit never invents a remote, never creates a repository, and never sends memories to a host you did not choose — memories can contain credentials, and convenience is not a reason to publish them somewhere you never picked.
307
+
308
+ The backup is a single `memgit-store.tar.gz`, not a directory tree: a 203 MB store is 10,295 small object files, and giving a sync client 10k files to reconcile every time is how you get a sync client that never finishes. It is staged and renamed atomically, keeping the old copy until the new one lands — an interrupted backup must never leave a corrupt file where a good one used to be.
309
+
310
+ ```bash
311
+ memgit backup status # where the last copy went, and what else is available
312
+ ```
313
+
314
+ ---
315
+
299
316
  ## Ranking you can prove
300
317
 
301
318
  Retrieval quality used to be adjusted on intuition. `memgit eval` replaces that with a measurement, using two frozen sets mined from the store itself:
@@ -348,6 +365,12 @@ memgit core heal # self-repair a guide that has drifted
348
365
  # (a project's FIRST guide is created automatically
349
366
  # once it holds 5+ memories — no command needed)
350
367
 
368
+ # Durability — automatic, no human command required
369
+ memgit backup status # last copy, staleness, available destinations
370
+ memgit backup now # force one immediately
371
+ memgit backup set <path> # pin a destination
372
+ memgit backup off / on # control the automatic path
373
+
351
374
  # Retrieval evaluation — prove a ranking change helped
352
375
  memgit eval mine # freeze a regression set from real recall events
353
376
  memgit eval mine --synthetic # non-circular set: query each memory by its own `why`
@@ -473,7 +496,7 @@ git clone https://github.com/code4161/memgit.git
473
496
  cd memgit
474
497
  python -m venv .venv && source .venv/bin/activate
475
498
  pip install -e ".[dev]"
476
- pytest # 404 tests, all passing, < 5 seconds
499
+ pytest # 431 tests, all passing, < 5 seconds
477
500
  ```
478
501
 
479
502
  See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -508,6 +531,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
508
531
  - [x] `memgit eval` — measured retrieval quality, real + non-circular sets (v0.8.0)
509
532
  - [x] Usage-aware ranking — the recall ledger feeds relevance, not just the guide (v0.8.0)
510
533
  - [x] Automatic core-guide bootstrap — the self-improving loop starts itself (v0.8.0)
534
+ - [x] Automatic off-machine backup — no human command required (v0.9.0)
511
535
  - [ ] JetBrains plugin (Phase 3)
512
536
  - [ ] Semantic search via embeddings — gated on `memgit eval` showing a real gain (Phase 4)
513
537
  - [ ] Public benchmark numbers (LongMemEval, LoCoMo) (Phase 4)
@@ -10,7 +10,7 @@ Version-controlled, cross-AI context that persists, diffs, rolls back, and syncs
10
10
 
11
11
  [![PyPI](https://img.shields.io/pypi/v/memgit)](https://pypi.org/project/memgit/)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
13
- [![Tests](https://img.shields.io/badge/tests-404%20passing-brightgreen)](tests/)
13
+ [![Tests](https://img.shields.io/badge/tests-431%20passing-brightgreen)](tests/)
14
14
 
15
15
  ---
16
16
 
@@ -262,6 +262,23 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
262
262
 
263
263
  ---
264
264
 
265
+ ## Backups that actually happen
266
+
267
+ memgit's premise is that the AI is the operator — but backup used to require a human to remember `memgit git init --remote <url>` and keep pushing. On this project's own store that meant 1,734 memories on one disk with no copy anywhere, five weeks in. A maintenance task that needs a human command is a task that will not happen.
268
+
269
+ Since 0.9.0 it runs itself, at the end of a session, with the safety boundary drawn at **network egress rather than effort**:
270
+
271
+ - **Local destinations are automatic** — a cloud-synced folder you already have (iCloud, Dropbox, Google Drive, OneDrive) or an external volume. memgit copies files; it opens no connection and signs up for no service. Your existing sync client does the rest.
272
+ - **A git remote is pushed to only if you already configured one.** memgit never invents a remote, never creates a repository, and never sends memories to a host you did not choose — memories can contain credentials, and convenience is not a reason to publish them somewhere you never picked.
273
+
274
+ The backup is a single `memgit-store.tar.gz`, not a directory tree: a 203 MB store is 10,295 small object files, and giving a sync client 10k files to reconcile every time is how you get a sync client that never finishes. It is staged and renamed atomically, keeping the old copy until the new one lands — an interrupted backup must never leave a corrupt file where a good one used to be.
275
+
276
+ ```bash
277
+ memgit backup status # where the last copy went, and what else is available
278
+ ```
279
+
280
+ ---
281
+
265
282
  ## Ranking you can prove
266
283
 
267
284
  Retrieval quality used to be adjusted on intuition. `memgit eval` replaces that with a measurement, using two frozen sets mined from the store itself:
@@ -314,6 +331,12 @@ memgit core heal # self-repair a guide that has drifted
314
331
  # (a project's FIRST guide is created automatically
315
332
  # once it holds 5+ memories — no command needed)
316
333
 
334
+ # Durability — automatic, no human command required
335
+ memgit backup status # last copy, staleness, available destinations
336
+ memgit backup now # force one immediately
337
+ memgit backup set <path> # pin a destination
338
+ memgit backup off / on # control the automatic path
339
+
317
340
  # Retrieval evaluation — prove a ranking change helped
318
341
  memgit eval mine # freeze a regression set from real recall events
319
342
  memgit eval mine --synthetic # non-circular set: query each memory by its own `why`
@@ -439,7 +462,7 @@ git clone https://github.com/code4161/memgit.git
439
462
  cd memgit
440
463
  python -m venv .venv && source .venv/bin/activate
441
464
  pip install -e ".[dev]"
442
- pytest # 404 tests, all passing, < 5 seconds
465
+ pytest # 431 tests, all passing, < 5 seconds
443
466
  ```
444
467
 
445
468
  See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -474,6 +497,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
474
497
  - [x] `memgit eval` — measured retrieval quality, real + non-circular sets (v0.8.0)
475
498
  - [x] Usage-aware ranking — the recall ledger feeds relevance, not just the guide (v0.8.0)
476
499
  - [x] Automatic core-guide bootstrap — the self-improving loop starts itself (v0.8.0)
500
+ - [x] Automatic off-machine backup — no human command required (v0.9.0)
477
501
  - [ ] JetBrains plugin (Phase 3)
478
502
  - [ ] Semantic search via embeddings — gated on `memgit eval` showing a real gain (Phase 4)
479
503
  - [ ] Public benchmark numbers (LongMemEval, LoCoMo) (Phase 4)
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -0,0 +1,377 @@
1
+ """Durability that does not wait for a human.
2
+
3
+ memgit's operating premise is that the AI is the operator. Backup was the one
4
+ place that premise broke: off-machine safety required someone to remember to
5
+ run `memgit git init --remote <url>` and then keep pushing. On a store audited
6
+ at 1,734 memories across five weeks of daily use, it had never been run once —
7
+ the entire memory set existed on a single disk with no copy anywhere.
8
+
9
+ A maintenance task that needs a human command is a maintenance task that will
10
+ not happen. So this module makes durability automatic, with the safety boundary
11
+ drawn at *network egress* rather than at *effort*:
12
+
13
+ * LOCAL destinations (a cloud-synced folder the user already has, an external
14
+ volume) are used AUTOMATICALLY. memgit copies files; it opens no
15
+ connection and signs up for no service. Whatever sync client the user
16
+ already trusts does the rest.
17
+ * A GIT REMOTE is pushed to automatically ONLY when the user has already
18
+ configured one. memgit never invents a remote, never creates a repository,
19
+ and never sends memories to a host the user has not already chosen.
20
+
21
+ That distinction matters because memories are not neutral text: a prior audit
22
+ on this very store found client credentials among them. Convenience is not a
23
+ reason to publish someone's private notes to a service they never picked.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import subprocess
30
+ from dataclasses import dataclass, asdict
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Optional
34
+
35
+ #: Backup state lives beside the store, not inside the object DB — it is
36
+ #: operational metadata, not memory, and must not churn the index.
37
+ _STATE = 'backup.json'
38
+
39
+ #: A backup older than this is stale enough to redo on the next quiet moment.
40
+ STALE_AFTER_HOURS = 24
41
+
42
+ #: Skipped when mirroring: caches are per-session scratch, and the flat
43
+ #: memories/ export is regenerated on demand. Everything needed to reconstruct
44
+ #: the store (objects, refs, index, HEAD, config) is copied.
45
+ _MIRROR_SKIP = {'cache'}
46
+
47
+
48
+ @dataclass
49
+ class Destination:
50
+ kind: str # synced | volume | remote | path
51
+ label: str # human-readable, e.g. "iCloud Drive"
52
+ target: str # filesystem path, or git remote name
53
+ auto_ok: bool # may be used without asking (no new network egress)
54
+
55
+
56
+ @dataclass
57
+ class BackupState:
58
+ last_ok: Optional[str] = None # ISO timestamp
59
+ last_target: Optional[str] = None
60
+ last_kind: Optional[str] = None
61
+ last_error: Optional[str] = None
62
+ memories: int = 0
63
+ disabled: bool = False
64
+ pinned: Optional[str] = None # operator-chosen destination path
65
+
66
+
67
+ def _state_path(repo) -> Path:
68
+ return repo.path / _STATE
69
+
70
+
71
+ def read_state(repo) -> BackupState:
72
+ try:
73
+ raw = json.loads(_state_path(repo).read_text(encoding='utf-8'))
74
+ return BackupState(**{k: v for k, v in raw.items()
75
+ if k in BackupState.__dataclass_fields__})
76
+ except (OSError, json.JSONDecodeError, TypeError):
77
+ return BackupState()
78
+
79
+
80
+ def write_state(repo, state: BackupState) -> None:
81
+ try:
82
+ _state_path(repo).write_text(json.dumps(asdict(state), indent=1),
83
+ encoding='utf-8')
84
+ except OSError:
85
+ pass
86
+
87
+
88
+ # ── destination discovery ─────────────────────────────────────────────────────
89
+
90
+ def _synced_folder_candidates(home: Path) -> list[tuple[str, Path]]:
91
+ """Cloud-sync roots this machine already has. Presence is the whole test —
92
+ if the directory exists the user already uses (and trusts) that service."""
93
+ return [
94
+ ('iCloud Drive', home / 'Library' / 'Mobile Documents' / 'com~apple~CloudDocs'),
95
+ ('Dropbox', home / 'Dropbox'),
96
+ ('Google Drive', home / 'Library' / 'CloudStorage'),
97
+ ('OneDrive', home / 'OneDrive'),
98
+ ('Sync', home / 'Sync'),
99
+ ]
100
+
101
+
102
+ def detect_destinations(repo, home: Optional[Path] = None) -> list[Destination]:
103
+ """Every viable backup target, best-first.
104
+
105
+ Ordering is deliberate: a configured git remote is the user's own explicit
106
+ choice and wins; then cloud-synced folders (genuinely off-machine); then
107
+ external volumes (off-disk, but only as good as the drive being attached).
108
+ """
109
+ home = home or Path.home()
110
+ out: list[Destination] = []
111
+
112
+ remote = _configured_remote(repo)
113
+ if remote:
114
+ out.append(Destination('remote', f'git remote ({remote})', remote,
115
+ auto_ok=True))
116
+
117
+ for label, path in _synced_folder_candidates(home):
118
+ if path.is_dir():
119
+ out.append(Destination('synced', label, str(path / 'memgit-backup'),
120
+ auto_ok=True))
121
+
122
+ for vol in _external_volumes():
123
+ out.append(Destination('volume', f'volume {vol.name}',
124
+ str(vol / 'memgit-backup'), auto_ok=True))
125
+
126
+ return out
127
+
128
+
129
+ def _external_volumes() -> list[Path]:
130
+ """Mounted volumes that are not the boot disk."""
131
+ vols = Path('/Volumes')
132
+ if not vols.is_dir():
133
+ return []
134
+ out = []
135
+ try:
136
+ for v in sorted(vols.iterdir()):
137
+ try:
138
+ if v.is_dir() and not v.is_symlink() and os.access(v, os.W_OK):
139
+ out.append(v)
140
+ except OSError:
141
+ continue
142
+ except OSError:
143
+ return []
144
+ return out
145
+
146
+
147
+ def _configured_remote(repo) -> Optional[str]:
148
+ """Name of a git remote already configured on the store, or None.
149
+
150
+ memgit NEVER creates one. A remote here means the user chose a host.
151
+ """
152
+ root = repo.path.parent
153
+ if not (root / '.git').exists():
154
+ return None
155
+ try:
156
+ r = subprocess.run(['git', 'remote'], cwd=root, capture_output=True,
157
+ text=True, timeout=10)
158
+ names = [n.strip() for n in r.stdout.splitlines() if n.strip()]
159
+ return names[0] if names else None
160
+ except Exception:
161
+ return None
162
+
163
+
164
+ # ── running a backup ──────────────────────────────────────────────────────────
165
+
166
+ #: Archive name inside the destination directory. Fixed, not timestamped:
167
+ #: an unbounded pile of dated archives in someone's iCloud is a bug, and
168
+ #: memgit's own history already provides point-in-time recovery.
169
+ ARCHIVE_NAME = 'memgit-store.tar.gz'
170
+
171
+
172
+ def mirror_store(repo, target: Path) -> int:
173
+ """Write the store to `target` as ONE compressed archive. Returns file count.
174
+
175
+ An archive rather than a directory tree, because the realistic destination
176
+ is a cloud-synced folder: the audited store is 203 MB across 10,295 small
177
+ object files, and handing a sync client 10k files to reconcile on every
178
+ backup is how you get a sync client that never finishes. One file also
179
+ makes the swap below genuinely atomic.
180
+
181
+ Written to a staging name and renamed over the target, with the old copy
182
+ kept until the new one lands — an interrupted backup must never destroy the
183
+ good copy it was replacing. That failure mode does not lose data loudly; it
184
+ leaves a corrupt file where safety used to be, which is worse.
185
+ """
186
+ import tarfile
187
+
188
+ target_dir = Path(target)
189
+ target_dir.mkdir(parents=True, exist_ok=True)
190
+ final = target_dir / ARCHIVE_NAME
191
+ staging = target_dir / (ARCHIVE_NAME + '.incoming')
192
+ previous = target_dir / (ARCHIVE_NAME + '.previous')
193
+ staging.unlink(missing_ok=True)
194
+
195
+ src_root = repo.path.parent
196
+ files = 0
197
+
198
+ def _filter(info: 'tarfile.TarInfo'):
199
+ nonlocal files
200
+ parts = Path(info.name).parts
201
+ if any(p in _MIRROR_SKIP for p in parts):
202
+ return None
203
+ if info.issym() or info.islnk():
204
+ return None
205
+ if info.isfile():
206
+ files += 1
207
+ return info
208
+
209
+ with tarfile.open(staging, 'w:gz', compresslevel=6) as tar:
210
+ tar.add(src_root, arcname='memgit-store', filter=_filter)
211
+
212
+ (target_dir / 'RESTORE.txt').write_text(
213
+ f'memgit store backup\n'
214
+ f'source : {src_root}\n'
215
+ f'written : {datetime.now(timezone.utc).isoformat()}\n'
216
+ f'files : {files}\n'
217
+ f'archive : {ARCHIVE_NAME}\n\n'
218
+ f'To restore:\n'
219
+ f' tar -xzf {ARCHIVE_NAME}\n'
220
+ f' rm -rf ~/.claude/memgit-store\n'
221
+ f' mv memgit-store ~/.claude/memgit-store\n'
222
+ f' memgit fsck\n',
223
+ encoding='utf-8')
224
+
225
+ previous.unlink(missing_ok=True)
226
+ if final.exists():
227
+ final.rename(previous)
228
+ staging.rename(final)
229
+ previous.unlink(missing_ok=True)
230
+ return files
231
+
232
+
233
+ def push_remote(repo, remote: str) -> None:
234
+ """Export flat memories, commit, and push to an ALREADY-configured remote."""
235
+ root = repo.path.parent
236
+ repo.write_flat()
237
+ subprocess.run(['git', 'add', '-A'], cwd=root, capture_output=True, timeout=60)
238
+ subprocess.run(
239
+ ['git', 'commit', '-m',
240
+ f'memgit backup {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")}'],
241
+ cwd=root, capture_output=True, timeout=60)
242
+ r = subprocess.run(['git', 'push', remote, 'HEAD'], cwd=root,
243
+ capture_output=True, text=True, timeout=180)
244
+ if r.returncode != 0:
245
+ raise RuntimeError((r.stderr or 'git push failed').strip()[:300])
246
+
247
+
248
+ def run_backup(repo, dest: Optional[Destination] = None,
249
+ home: Optional[Path] = None) -> tuple[bool, str]:
250
+ """Back the store up to `dest` (or the best automatic one). (ok, message)."""
251
+ state = read_state(repo)
252
+ if dest is None:
253
+ if state.pinned:
254
+ dest = Destination('path', 'pinned', state.pinned, auto_ok=True)
255
+ else:
256
+ options = [d for d in detect_destinations(repo, home) if d.auto_ok]
257
+ dest = options[0] if options else None
258
+ if dest is None:
259
+ return False, ('no backup destination available — no git remote is '
260
+ 'configured and no cloud-synced folder or external '
261
+ 'volume was found')
262
+
263
+ try:
264
+ if dest.kind == 'remote':
265
+ push_remote(repo, dest.target)
266
+ detail = f'pushed to {dest.label}'
267
+ else:
268
+ n = mirror_store(repo, Path(dest.target))
269
+ detail = f'mirrored {n} files to {dest.target}'
270
+ except Exception as e: # noqa: BLE001
271
+ state.last_error = str(e)[:300]
272
+ write_state(repo, state)
273
+ return False, f'backup failed: {state.last_error}'
274
+
275
+ try:
276
+ count = len(repo.list())
277
+ except Exception:
278
+ count = state.memories
279
+ state.last_ok = datetime.now(timezone.utc).isoformat()
280
+ state.last_target = dest.target
281
+ state.last_kind = dest.kind
282
+ state.last_error = None
283
+ state.memories = count
284
+ write_state(repo, state)
285
+ return True, detail
286
+
287
+
288
+ # ── the automatic path ────────────────────────────────────────────────────────
289
+
290
+ def hours_since_backup(repo, now: Optional[datetime] = None) -> Optional[float]:
291
+ """Hours since the last successful backup, or None if there has never been one."""
292
+ state = read_state(repo)
293
+ if not state.last_ok:
294
+ return None
295
+ try:
296
+ last = datetime.fromisoformat(state.last_ok)
297
+ except ValueError:
298
+ return None
299
+ if last.tzinfo is None:
300
+ last = last.replace(tzinfo=timezone.utc)
301
+ now = now or datetime.now(timezone.utc)
302
+ return (now - last).total_seconds() / 3600.0
303
+
304
+
305
+ def is_stale(repo, now: Optional[datetime] = None) -> bool:
306
+ age = hours_since_backup(repo, now)
307
+ return age is None or age >= STALE_AFTER_HOURS
308
+
309
+
310
+ def auto_backup_allowed(repo) -> bool:
311
+ """Whether the UNATTENDED path may run for this store.
312
+
313
+ Guard against a class of bug found the hard way: a test that exercised the
314
+ end-of-session sync path fired a real backup into the developer's actual
315
+ iCloud folder, because destination discovery reads the true home directory
316
+ while the store was a temp path. Anything that writes outside the store,
317
+ unprompted, must be certain it is operating on the user's real store.
318
+
319
+ `MEMGIT_STORE` is set by the test suite (and by anyone pointing memgit at a
320
+ scratch store), so it is exactly the signal for "this is not the store whose
321
+ durability I am responsible for". Explicit `memgit backup now` is unaffected
322
+ — that is a human or agent asking on purpose.
323
+ """
324
+ if os.environ.get('MEMGIT_STORE'):
325
+ return False
326
+ if os.environ.get('PYTEST_CURRENT_TEST'):
327
+ return False
328
+ return True
329
+
330
+
331
+ def maybe_auto_backup(repo, home: Optional[Path] = None,
332
+ now: Optional[datetime] = None) -> Optional[str]:
333
+ """Back up if it is due and a safe destination exists. Silent and best-effort.
334
+
335
+ Called from the end-of-session sync path, alongside the other housekeeping
336
+ the AI operator never has to think about. Returns a short message when a
337
+ backup ran, else None. Never raises into a hook.
338
+ """
339
+ try:
340
+ if not auto_backup_allowed(repo):
341
+ return None
342
+ state = read_state(repo)
343
+ if state.disabled:
344
+ return None
345
+ if not is_stale(repo, now):
346
+ return None
347
+ ok, msg = run_backup(repo, home=home)
348
+ return msg if ok else None
349
+ except Exception: # noqa: BLE001
350
+ return None
351
+
352
+
353
+ def status_line(repo, now: Optional[datetime] = None) -> Optional[str]:
354
+ """One line for the resume digest when durability needs attention.
355
+
356
+ Silent when a recent backup exists — a warning that fires every session is
357
+ a warning that gets ignored. Loud, with the exact call to make, when there
358
+ is no copy at all: that is the state where a disk failure is total loss.
359
+ """
360
+ state = read_state(repo)
361
+ if state.disabled:
362
+ return None
363
+ age = hours_since_backup(repo, now)
364
+ if age is None:
365
+ try:
366
+ n = len(repo.list())
367
+ except Exception:
368
+ n = 0
369
+ if n < 20:
370
+ return None # nothing worth protecting yet
371
+ # Terse on purpose. This rides a hard-budgeted digest injected every
372
+ # session; the reasoning and the destination list live in
373
+ # `memgit backup status`, one command away.
374
+ return f'no backup — {n} memories on one disk. `memgit backup now`'
375
+ if age >= STALE_AFTER_HOURS * 7:
376
+ return f'last backup {int(age / 24)}d ago. `memgit backup now`'
377
+ return None
@@ -485,6 +485,13 @@ def _maybe_auto_core(repo) -> None:
485
485
  repo.gc_caches()
486
486
  except Exception:
487
487
  pass
488
+ # Durability, unattended. A backup that needs someone to remember a command
489
+ # is a backup that does not exist — measured: zero in five weeks of use.
490
+ try:
491
+ from .backup import maybe_auto_backup
492
+ maybe_auto_backup(repo)
493
+ except Exception:
494
+ pass
488
495
 
489
496
 
490
497
  @core.command('refresh')
@@ -715,6 +722,8 @@ def resume(checkpoints, recent, plain, fmt_json, project):
715
722
  f'[dim](memgit search "<topic>")[/dim]')
716
723
  if ctx.get('maintenance'):
717
724
  console.print(f'\n[yellow]maintenance:[/yellow] {ctx["maintenance"]}')
725
+ if ctx.get('durability'):
726
+ console.print(f'\n[red]durability:[/red] {ctx["durability"]}')
718
727
  console.print()
719
728
 
720
729
 
@@ -855,6 +864,12 @@ def _render_resume_plain(ctx: dict) -> str:
855
864
  if ctx.get('maintenance'):
856
865
  lines.append('')
857
866
  lines.append(f'## Maintenance needed\n- {ctx["maintenance"]}')
867
+ # Durability is separate from maintenance and never trimmed: a store with
868
+ # no copy anywhere is one disk failure from total loss, and the operator
869
+ # who has to act on it is the agent reading this digest.
870
+ if ctx.get('durability'):
871
+ lines.append('')
872
+ lines.append(f'## Durability\n- {ctx["durability"]}')
858
873
  if ctx.get('project_is_new'):
859
874
  lines.append('')
860
875
  lines.append(
@@ -1963,6 +1978,109 @@ def metrics(fmt_json, reset):
1963
1978
  console.print()
1964
1979
 
1965
1980
 
1981
+ @cli.group(invoke_without_command=True)
1982
+ @click.pass_context
1983
+ def backup(ctx):
1984
+ """Off-machine durability — automatic, no human command required.
1985
+
1986
+ Local destinations (a cloud-synced folder you already have, an external
1987
+ volume) are used automatically: memgit copies files and opens no network
1988
+ connection. A git remote is pushed to only if you have already configured
1989
+ one — memgit never invents a remote or creates a repository, because
1990
+ memories can contain credentials and must not land on a service you did
1991
+ not choose.
1992
+ """
1993
+ if ctx.invoked_subcommand is None:
1994
+ ctx.invoke(backup_status)
1995
+
1996
+
1997
+ @backup.command('status')
1998
+ def backup_status():
1999
+ """Where the last copy went, how old it is, and what else is available."""
2000
+ repo = _require_repo()
2001
+ from .backup import (read_state, detect_destinations, hours_since_backup,
2002
+ STALE_AFTER_HOURS)
2003
+ state = read_state(repo)
2004
+ age = hours_since_backup(repo)
2005
+
2006
+ if state.disabled:
2007
+ console.print('[yellow]backup disabled[/yellow] — re-enable with '
2008
+ '`memgit backup on`')
2009
+ elif age is None:
2010
+ console.print(f'[red]no backup has ever run[/red] — '
2011
+ f'{len(repo.list())} memories exist on one disk')
2012
+ else:
2013
+ colour = 'green' if age < STALE_AFTER_HOURS else 'yellow'
2014
+ when = f'{age:.1f}h ago' if age < 48 else f'{age / 24:.1f}d ago'
2015
+ console.print(f'[{colour}]last backup {when}[/{colour}] '
2016
+ f'[dim]{state.memories} memories → {state.last_target}[/dim]')
2017
+ if state.last_error:
2018
+ console.print(f' [red]last error:[/red] {state.last_error}')
2019
+
2020
+ dests = detect_destinations(repo)
2021
+ console.print()
2022
+ if not dests:
2023
+ console.print('[yellow]no destination available[/yellow] — no git remote '
2024
+ 'configured, no cloud-synced folder or external volume found.')
2025
+ console.print('[dim]Set one explicitly: `memgit backup set <path>`[/dim]')
2026
+ return
2027
+ console.print('[bold]Available destinations[/bold] [dim](best first)[/dim]')
2028
+ for d in dests:
2029
+ mark = '[green]auto[/green]' if d.auto_ok else '[yellow]ask[/yellow]'
2030
+ console.print(f' {mark} {d.label:24} [dim]{d.target}[/dim]')
2031
+
2032
+
2033
+ @backup.command('now')
2034
+ @click.option('--to', 'to_path', default=None,
2035
+ help='Explicit destination path (overrides auto-detection)')
2036
+ def backup_now(to_path):
2037
+ """Back up immediately."""
2038
+ repo = _require_repo()
2039
+ from .backup import run_backup, Destination
2040
+ dest = Destination('path', 'explicit', to_path, True) if to_path else None
2041
+ ok, msg = run_backup(repo, dest)
2042
+ if ok:
2043
+ console.print(f'[green]backup ok[/green] — {msg}')
2044
+ else:
2045
+ err.print(f'[red]{msg}[/red]')
2046
+ sys.exit(1)
2047
+
2048
+
2049
+ @backup.command('set')
2050
+ @click.argument('path')
2051
+ def backup_set(path):
2052
+ """Pin a destination directory for every future backup."""
2053
+ repo = _require_repo()
2054
+ from .backup import read_state, write_state
2055
+ state = read_state(repo)
2056
+ state.pinned = str(Path(path).expanduser())
2057
+ state.disabled = False
2058
+ write_state(repo, state)
2059
+ console.print(f'[green]backup destination pinned[/green] → {state.pinned}')
2060
+
2061
+
2062
+ @backup.command('off')
2063
+ def backup_off():
2064
+ """Stop automatic backups."""
2065
+ repo = _require_repo()
2066
+ from .backup import read_state, write_state
2067
+ state = read_state(repo)
2068
+ state.disabled = True
2069
+ write_state(repo, state)
2070
+ console.print('[yellow]automatic backup disabled[/yellow]')
2071
+
2072
+
2073
+ @backup.command('on')
2074
+ def backup_on():
2075
+ """Resume automatic backups."""
2076
+ repo = _require_repo()
2077
+ from .backup import read_state, write_state
2078
+ state = read_state(repo)
2079
+ state.disabled = False
2080
+ write_state(repo, state)
2081
+ console.print('[green]automatic backup enabled[/green]')
2082
+
2083
+
1966
2084
  @cli.group(invoke_without_command=True)
1967
2085
  @click.pass_context
1968
2086
  def eval(ctx):
@@ -229,6 +229,37 @@ def collect_skills(root: Path, home: Optional[Path] = None) -> list[tuple[str, s
229
229
  return sorted(found.items())
230
230
 
231
231
 
232
+ #: Per-skill budget in the guide's routing list. The guide is injected every
233
+ #: session on every host, so it is the single most expensive surface memgit
234
+ #: owns — and a 2026-08-05 audit measured it as the LOWEST-yield memory type
235
+ #: (2.10 recalls/memory vs 17.5 for feedback). Skill `description` frontmatter
236
+ #: is written to persuade a model to invoke the skill and routinely runs 600+
237
+ #: chars; on a store with 18 skills that was 6,968 of the guide's 9,425 chars.
238
+ #: The guide only needs to answer "which skill covers this?" — the full text
239
+ #: is in the skill itself, one Read away.
240
+ _SKILL_DESC_MAX = 150
241
+
242
+
243
+ def _routing_summary(desc: str) -> str:
244
+ """Trim a skill description to the part that answers 'is this the one?'.
245
+
246
+ Prefers a clean sentence break inside the budget; falls back to a word
247
+ boundary. Never mid-word, and never a bare ellipsis with no content.
248
+ """
249
+ desc = ' '.join((desc or '').split())
250
+ if len(desc) <= _SKILL_DESC_MAX:
251
+ return desc
252
+ window = desc[:_SKILL_DESC_MAX + 1]
253
+ for stop in ('. ', '; ', ' — ', ' - '):
254
+ cut = window.rfind(stop)
255
+ if cut >= _SKILL_DESC_MAX // 2:
256
+ return desc[:cut].rstrip(' .;—-') + '.'
257
+ cut = window.rfind(' ')
258
+ if cut < _SKILL_DESC_MAX // 2:
259
+ cut = _SKILL_DESC_MAX
260
+ return desc[:cut].rstrip(' .;,—-') + '…'
261
+
262
+
232
263
  def _store_scale(repo, project: Optional[str]) -> Optional[tuple[int, list[str]]]:
233
264
  """(memory count, top topic tags) for this project — the concrete evidence
234
265
  the guide leads with. Returns None when there is nothing worth stating."""
@@ -298,6 +329,7 @@ def build_seed(root: Path, home: Optional[Path] = None,
298
329
  if skills:
299
330
  lines += ["", "## Available skills (invoke by name when the task matches)"]
300
331
  for name, desc in skills:
332
+ desc = _routing_summary(desc)
301
333
  lines.append(f"- **{name}** — {desc}" if desc else f"- **{name}**")
302
334
 
303
335
  rule_files = []
@@ -996,8 +996,17 @@ class Repository:
996
996
  'entity_index': topics,
997
997
  'core_missing': core_missing,
998
998
  'maintenance': self.maintenance_hint(count),
999
+ 'durability': self._durability_hint(),
999
1000
  }
1000
1001
 
1002
+ def _durability_hint(self) -> Optional[str]:
1003
+ """Backup warning for the digest, or None while safe. Never raises."""
1004
+ try:
1005
+ from .backup import status_line
1006
+ return status_line(self)
1007
+ except Exception:
1008
+ return None
1009
+
1001
1010
  def _scoped_checkpoints(self, checkpoints: int,
1002
1011
  project: Optional[str],
1003
1012
  all_mnemonics: list[Mnemonic]) -> list[Checkpoint]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://memgit.dev
@@ -44,7 +44,7 @@ Version-controlled, cross-AI context that persists, diffs, rolls back, and syncs
44
44
 
45
45
  [![PyPI](https://img.shields.io/pypi/v/memgit)](https://pypi.org/project/memgit/)
46
46
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
47
- [![Tests](https://img.shields.io/badge/tests-404%20passing-brightgreen)](tests/)
47
+ [![Tests](https://img.shields.io/badge/tests-431%20passing-brightgreen)](tests/)
48
48
 
49
49
  ---
50
50
 
@@ -296,6 +296,23 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
296
296
 
297
297
  ---
298
298
 
299
+ ## Backups that actually happen
300
+
301
+ memgit's premise is that the AI is the operator — but backup used to require a human to remember `memgit git init --remote <url>` and keep pushing. On this project's own store that meant 1,734 memories on one disk with no copy anywhere, five weeks in. A maintenance task that needs a human command is a task that will not happen.
302
+
303
+ Since 0.9.0 it runs itself, at the end of a session, with the safety boundary drawn at **network egress rather than effort**:
304
+
305
+ - **Local destinations are automatic** — a cloud-synced folder you already have (iCloud, Dropbox, Google Drive, OneDrive) or an external volume. memgit copies files; it opens no connection and signs up for no service. Your existing sync client does the rest.
306
+ - **A git remote is pushed to only if you already configured one.** memgit never invents a remote, never creates a repository, and never sends memories to a host you did not choose — memories can contain credentials, and convenience is not a reason to publish them somewhere you never picked.
307
+
308
+ The backup is a single `memgit-store.tar.gz`, not a directory tree: a 203 MB store is 10,295 small object files, and giving a sync client 10k files to reconcile every time is how you get a sync client that never finishes. It is staged and renamed atomically, keeping the old copy until the new one lands — an interrupted backup must never leave a corrupt file where a good one used to be.
309
+
310
+ ```bash
311
+ memgit backup status # where the last copy went, and what else is available
312
+ ```
313
+
314
+ ---
315
+
299
316
  ## Ranking you can prove
300
317
 
301
318
  Retrieval quality used to be adjusted on intuition. `memgit eval` replaces that with a measurement, using two frozen sets mined from the store itself:
@@ -348,6 +365,12 @@ memgit core heal # self-repair a guide that has drifted
348
365
  # (a project's FIRST guide is created automatically
349
366
  # once it holds 5+ memories — no command needed)
350
367
 
368
+ # Durability — automatic, no human command required
369
+ memgit backup status # last copy, staleness, available destinations
370
+ memgit backup now # force one immediately
371
+ memgit backup set <path> # pin a destination
372
+ memgit backup off / on # control the automatic path
373
+
351
374
  # Retrieval evaluation — prove a ranking change helped
352
375
  memgit eval mine # freeze a regression set from real recall events
353
376
  memgit eval mine --synthetic # non-circular set: query each memory by its own `why`
@@ -473,7 +496,7 @@ git clone https://github.com/code4161/memgit.git
473
496
  cd memgit
474
497
  python -m venv .venv && source .venv/bin/activate
475
498
  pip install -e ".[dev]"
476
- pytest # 404 tests, all passing, < 5 seconds
499
+ pytest # 431 tests, all passing, < 5 seconds
477
500
  ```
478
501
 
479
502
  See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -508,6 +531,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
508
531
  - [x] `memgit eval` — measured retrieval quality, real + non-circular sets (v0.8.0)
509
532
  - [x] Usage-aware ranking — the recall ledger feeds relevance, not just the guide (v0.8.0)
510
533
  - [x] Automatic core-guide bootstrap — the self-improving loop starts itself (v0.8.0)
534
+ - [x] Automatic off-machine backup — no human command required (v0.9.0)
511
535
  - [ ] JetBrains plugin (Phase 3)
512
536
  - [ ] Semantic search via embeddings — gated on `memgit eval` showing a real gain (Phase 4)
513
537
  - [ ] Public benchmark numbers (LongMemEval, LoCoMo) (Phase 4)
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  memgit/__init__.py
5
+ memgit/backup.py
5
6
  memgit/cli.py
6
7
  memgit/delivery.py
7
8
  memgit/evaluate.py
@@ -37,6 +38,7 @@ memgit/cloud/sync.py
37
38
  tests/test_accrue.py
38
39
  tests/test_advanced.py
39
40
  tests/test_aliases.py
41
+ tests/test_backup.py
40
42
  tests/test_core.py
41
43
  tests/test_delivery.py
42
44
  tests/test_setup.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memgit"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,236 @@
1
+ """v0.9.0 — durability that does not wait for a human.
2
+
3
+ The premise under test: a maintenance task requiring a human command is a task
4
+ that will not happen (measured — `git init` was never run in five weeks on a
5
+ 1,734-memory store). These tests pin the automatic path AND the safety boundary
6
+ that keeps it from being reckless.
7
+ """
8
+ import os
9
+ import tarfile
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from memgit.models import Mnemonic
16
+ from memgit.repo import Repository
17
+
18
+
19
+ @pytest.fixture
20
+ def repo(tmp_path):
21
+ r = Repository.init(tmp_path / "store")
22
+ for i in range(30):
23
+ r.add(Mnemonic(type_code="lx", slug=f"m{i}", rule=f"lesson number {i}",
24
+ timestamp=datetime.now(timezone.utc), project="P"))
25
+ r.commit(message="seed", trigger="explicit")
26
+ return r
27
+
28
+
29
+ def _fake_home(tmp_path, *names) -> Path:
30
+ home = tmp_path / "home"
31
+ for n in names:
32
+ (home / n).mkdir(parents=True, exist_ok=True)
33
+ home.mkdir(exist_ok=True)
34
+ return home
35
+
36
+
37
+ # ── destination discovery ────────────────────────────────────────────────────
38
+
39
+ class TestDestinations:
40
+ def test_finds_icloud(self, repo, tmp_path):
41
+ from memgit.backup import detect_destinations
42
+ home = _fake_home(tmp_path, "Library/Mobile Documents/com~apple~CloudDocs")
43
+ dests = detect_destinations(repo, home)
44
+ assert any(d.label == "iCloud Drive" and d.kind == "synced" for d in dests)
45
+
46
+ def test_finds_dropbox(self, repo, tmp_path):
47
+ from memgit.backup import detect_destinations
48
+ home = _fake_home(tmp_path, "Dropbox")
49
+ assert any(d.label == "Dropbox" for d in detect_destinations(repo, home))
50
+
51
+ def test_no_destinations_when_nothing_present(self, repo, tmp_path, monkeypatch):
52
+ from memgit import backup
53
+ monkeypatch.setattr(backup, "_external_volumes", lambda: [])
54
+ home = _fake_home(tmp_path)
55
+ assert backup.detect_destinations(repo, home) == []
56
+
57
+ def test_never_invents_a_git_remote(self, repo, tmp_path):
58
+ """memgit must not create a repository or pick a host on the user's
59
+ behalf — memories can contain credentials."""
60
+ from memgit.backup import _configured_remote
61
+ assert _configured_remote(repo) is None
62
+
63
+ def test_configured_remote_ranks_first(self, repo, tmp_path, monkeypatch):
64
+ from memgit import backup
65
+ monkeypatch.setattr(backup, "_configured_remote", lambda r: "origin")
66
+ monkeypatch.setattr(backup, "_external_volumes", lambda: [])
67
+ home = _fake_home(tmp_path, "Dropbox")
68
+ dests = backup.detect_destinations(repo, home)
69
+ assert dests[0].kind == "remote"
70
+
71
+
72
+ # ── the archive ──────────────────────────────────────────────────────────────
73
+
74
+ class TestArchive:
75
+ def test_produces_one_file_not_a_tree(self, repo, tmp_path):
76
+ """10k small object files into a cloud-sync folder is how you get a
77
+ sync client that never finishes."""
78
+ from memgit.backup import mirror_store, ARCHIVE_NAME
79
+ dest = tmp_path / "dest"
80
+ mirror_store(repo, dest)
81
+ assert (dest / ARCHIVE_NAME).is_file()
82
+ assert not (dest / ".memgit").exists()
83
+
84
+ def test_archive_round_trips(self, repo, tmp_path):
85
+ """The only property that matters: it restores."""
86
+ from memgit.backup import mirror_store, ARCHIVE_NAME
87
+ dest = tmp_path / "dest"
88
+ mirror_store(repo, dest)
89
+ out = tmp_path / "restored"
90
+ out.mkdir()
91
+ with tarfile.open(dest / ARCHIVE_NAME) as tar:
92
+ tar.extractall(out, filter="data")
93
+ restored = Repository(out / "memgit-store" / ".memgit")
94
+ assert len(restored.list()) == 30
95
+ assert restored.fsck() == []
96
+
97
+ def test_cache_is_excluded(self, repo, tmp_path):
98
+ from memgit.backup import mirror_store, ARCHIVE_NAME
99
+ (repo.path / "cache" / "recall").mkdir(parents=True, exist_ok=True)
100
+ (repo.path / "cache" / "recall" / "sess").write_text("junk")
101
+ dest = tmp_path / "dest"
102
+ mirror_store(repo, dest)
103
+ with tarfile.open(dest / ARCHIVE_NAME) as tar:
104
+ names = tar.getnames()
105
+ assert not any("/cache/" in n for n in names)
106
+
107
+ def test_previous_archive_survives_until_new_one_lands(self, repo, tmp_path):
108
+ """An interrupted backup must never leave a corrupt file where a good
109
+ one used to be — that is worse than no backup, because it looks safe."""
110
+ from memgit.backup import mirror_store, ARCHIVE_NAME
111
+ dest = tmp_path / "dest"
112
+ mirror_store(repo, dest)
113
+ first = (dest / ARCHIVE_NAME).read_bytes()
114
+ repo.add(Mnemonic(type_code="lx", slug="later", rule="added after",
115
+ timestamp=datetime.now(timezone.utc), project="P"))
116
+ repo.commit(message="more", trigger="explicit")
117
+ mirror_store(repo, dest)
118
+ assert (dest / ARCHIVE_NAME).read_bytes() != first
119
+ assert not (dest / (ARCHIVE_NAME + ".incoming")).exists()
120
+ assert not (dest / (ARCHIVE_NAME + ".previous")).exists()
121
+
122
+ def test_writes_restore_instructions(self, repo, tmp_path):
123
+ from memgit.backup import mirror_store
124
+ dest = tmp_path / "dest"
125
+ mirror_store(repo, dest)
126
+ text = (dest / "RESTORE.txt").read_text()
127
+ assert "tar -xzf" in text and "memgit fsck" in text
128
+
129
+
130
+ # ── the automatic path + its safety boundary ─────────────────────────────────
131
+
132
+ class TestAutoBackup:
133
+ def test_refuses_to_run_unattended_on_a_scratch_store(self, repo, monkeypatch):
134
+ """Regression: a test exercising the sync path fired a real backup into
135
+ the developer's actual iCloud, because discovery reads the true home
136
+ while the store was a temp path."""
137
+ from memgit.backup import auto_backup_allowed, maybe_auto_backup
138
+ monkeypatch.setenv("MEMGIT_STORE", "/tmp/whatever")
139
+ assert auto_backup_allowed(repo) is False
140
+ assert maybe_auto_backup(repo) is None
141
+
142
+ def test_refuses_under_pytest(self, repo, monkeypatch):
143
+ from memgit.backup import auto_backup_allowed
144
+ monkeypatch.delenv("MEMGIT_STORE", raising=False)
145
+ # PYTEST_CURRENT_TEST is always set while a test is executing
146
+ assert os.environ.get("PYTEST_CURRENT_TEST")
147
+ assert auto_backup_allowed(repo) is False
148
+
149
+ def test_explicit_backup_is_never_blocked_by_the_guard(self, repo, tmp_path):
150
+ """`backup now` is someone asking on purpose — the guard is only for
151
+ the unattended path."""
152
+ from memgit.backup import run_backup, Destination
153
+ dest = Destination("path", "explicit", str(tmp_path / "dest"), True)
154
+ ok, msg = run_backup(repo, dest)
155
+ assert ok, msg
156
+
157
+ def test_staleness(self, repo):
158
+ from memgit.backup import (read_state, write_state, is_stale,
159
+ hours_since_backup, STALE_AFTER_HOURS)
160
+ assert is_stale(repo) is True # never backed up
161
+ assert hours_since_backup(repo) is None
162
+ s = read_state(repo)
163
+ s.last_ok = datetime.now(timezone.utc).isoformat()
164
+ write_state(repo, s)
165
+ assert is_stale(repo) is False
166
+ s.last_ok = (datetime.now(timezone.utc)
167
+ - timedelta(hours=STALE_AFTER_HOURS + 1)).isoformat()
168
+ write_state(repo, s)
169
+ assert is_stale(repo) is True
170
+
171
+ def test_disabled_state_is_respected(self, repo):
172
+ from memgit.backup import read_state, write_state, maybe_auto_backup
173
+ s = read_state(repo)
174
+ s.disabled = True
175
+ write_state(repo, s)
176
+ assert maybe_auto_backup(repo) is None
177
+
178
+ def test_state_records_success(self, repo, tmp_path):
179
+ from memgit.backup import run_backup, Destination, read_state
180
+ ok, _ = run_backup(repo, Destination("path", "x", str(tmp_path / "d"), True))
181
+ assert ok
182
+ s = read_state(repo)
183
+ assert s.last_ok and s.memories == 30 and s.last_error is None
184
+
185
+ def test_failure_is_recorded_not_raised(self, repo, monkeypatch, tmp_path):
186
+ from memgit import backup
187
+ def boom(*a, **k):
188
+ raise OSError("disk gone")
189
+ monkeypatch.setattr(backup, "mirror_store", boom)
190
+ ok, msg = backup.run_backup(
191
+ repo, backup.Destination("path", "x", str(tmp_path / "d"), True))
192
+ assert ok is False and "disk gone" in msg
193
+ assert backup.read_state(repo).last_error
194
+
195
+
196
+ # ── the digest warning ───────────────────────────────────────────────────────
197
+
198
+ class TestDurabilityHint:
199
+ def test_warns_when_no_backup_exists(self, repo):
200
+ from memgit.backup import status_line
201
+ line = status_line(repo)
202
+ assert line and "backup" in line.lower() and "30 memories" in line
203
+
204
+ def test_silent_for_a_tiny_store(self, tmp_path):
205
+ """Nothing worth protecting yet — a warning that always fires is noise."""
206
+ from memgit.backup import status_line
207
+ r = Repository.init(tmp_path / "small")
208
+ r.add(Mnemonic(type_code="lx", slug="one", rule="only one",
209
+ timestamp=datetime.now(timezone.utc)))
210
+ r.commit(message="x", trigger="explicit")
211
+ assert status_line(r) is None
212
+
213
+ def test_silent_right_after_a_backup(self, repo):
214
+ from memgit.backup import read_state, write_state, status_line
215
+ s = read_state(repo)
216
+ s.last_ok = datetime.now(timezone.utc).isoformat()
217
+ write_state(repo, s)
218
+ assert status_line(repo) is None
219
+
220
+ def test_warns_again_when_very_stale(self, repo):
221
+ from memgit.backup import read_state, write_state, status_line
222
+ s = read_state(repo)
223
+ s.last_ok = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
224
+ write_state(repo, s)
225
+ line = status_line(repo)
226
+ assert line and "30d ago" in line
227
+
228
+ def test_silent_when_disabled(self, repo):
229
+ from memgit.backup import read_state, write_state, status_line
230
+ s = read_state(repo)
231
+ s.disabled = True
232
+ write_state(repo, s)
233
+ assert status_line(repo) is None
234
+
235
+ def test_hint_reaches_the_resume_digest(self, repo):
236
+ assert repo.resume_context().get("durability")
@@ -565,14 +565,47 @@ class TestTokenBudget:
565
565
  assert added_tokens <= 250, f"new sections cost ~{added_tokens:.0f} tokens"
566
566
 
567
567
  def test_resume_bounded_on_large_store(self, repo):
568
+ """Steady state — a store WITH a backup — must stay under budget.
569
+
570
+ This is the case that runs every session for a healthy store, so it is
571
+ the one the bound has to hold for.
572
+ """
568
573
  from memgit.cli import _format_resume_plain
574
+ from memgit.backup import read_state, write_state
575
+ from datetime import datetime, timezone
569
576
  self._big_store(repo)
570
577
  for i in range(9):
571
578
  repo.add(_mk(f"e{i}-status", type_code="tr", rule="state " * 10))
579
+ state = read_state(repo)
580
+ state.last_ok = datetime.now(timezone.utc).isoformat()
581
+ write_state(repo, state)
572
582
  text = _format_resume_plain(repo.resume_context())
573
583
  approx_tokens = len(text) / 4 # chars/4 heuristic
574
584
  assert approx_tokens <= 900, f"digest too big: ~{approx_tokens:.0f} tokens"
575
585
 
586
+ def test_durability_warning_is_cheap_and_self_clearing(self, repo):
587
+ """With no backup the digest carries a warning; it costs ~20 tokens and
588
+ disappears the moment a backup exists (including an automatic one), so
589
+ the steady-state digest is unchanged by this feature."""
590
+ from memgit.cli import _format_resume_plain
591
+ from memgit.backup import read_state, write_state
592
+ from datetime import datetime, timezone
593
+ self._big_store(repo)
594
+ for i in range(9):
595
+ repo.add(_mk(f"e{i}-status", type_code="tr", rule="state " * 10))
596
+
597
+ warned = _format_resume_plain(repo.resume_context())
598
+ assert "backup" in warned.lower()
599
+ assert len(warned) / 4 <= 930, "the warning itself must stay small"
600
+
601
+ state = read_state(repo)
602
+ state.last_ok = datetime.now(timezone.utc).isoformat()
603
+ write_state(repo, state)
604
+ clean = _format_resume_plain(repo.resume_context())
605
+ assert "Durability" not in clean
606
+ assert (len(warned) - len(clean)) / 4 <= 30, \
607
+ "durability warning must cost under ~30 tokens"
608
+
576
609
  def test_server_description_pins_authority_framing(self):
577
610
  from memgit.mcp_server import _SERVER_DESCRIPTION, _TYPE_DESCRIPTIONS
578
611
  assert "AUTHORITY" in _SERVER_DESCRIPTION
@@ -368,6 +368,32 @@ class TestSeedEvidence:
368
368
  assert "**9 saved memories**" in body
369
369
  assert "crypto" in body
370
370
 
371
+ def test_long_skill_descriptions_are_trimmed(self, tmp_path):
372
+ """The guide is injected every session on every host; skill frontmatter
373
+ is written to persuade and runs long. Routing needs a sentence."""
374
+ from memgit.delivery import build_seed, _SKILL_DESC_MAX
375
+ skills = tmp_path / ".claude" / "skills" / "verbose"
376
+ skills.mkdir(parents=True)
377
+ long_desc = ("Mandatory entry point: read this first for any request. "
378
+ + "Additional qualifying detail. " * 30)
379
+ (skills / "SKILL.md").write_text(
380
+ f"---\nname: verbose\ndescription: {long_desc}\n---\nbody\n")
381
+ body = build_seed(tmp_path, home=tmp_path)
382
+ line = next(l for l in body.splitlines() if l.startswith("- **verbose**"))
383
+ assert len(line) < _SKILL_DESC_MAX + 40
384
+ assert "Mandatory entry point" in line, "the routing signal must survive"
385
+
386
+ def test_short_descriptions_untouched(self, tmp_path):
387
+ from memgit.delivery import _routing_summary
388
+ d = "Deploy the portfolio to Vercel."
389
+ assert _routing_summary(d) == d
390
+
391
+ def test_trim_never_cuts_mid_word(self, tmp_path):
392
+ from memgit.delivery import _routing_summary
393
+ out = _routing_summary("alpha " * 100)
394
+ assert not out.rstrip("…").endswith("alph")
395
+ assert out.endswith(("…", ".")) and len(out.strip("…. ")) > 20
396
+
371
397
  def test_seed_stays_quiet_below_the_bar(self, tmp_path):
372
398
  from memgit.delivery import build_seed
373
399
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes