memgit 0.8.1__tar.gz → 0.9.1__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.
- {memgit-0.8.1 → memgit-0.9.1}/PKG-INFO +29 -3
- {memgit-0.8.1 → memgit-0.9.1}/README.md +28 -2
- {memgit-0.8.1 → memgit-0.9.1}/memgit/__init__.py +1 -1
- memgit-0.9.1/memgit/backup.py +377 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/cli.py +118 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/repo.py +9 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit.egg-info/PKG-INFO +29 -3
- {memgit-0.8.1 → memgit-0.9.1}/memgit.egg-info/SOURCES.txt +2 -0
- {memgit-0.8.1 → memgit-0.9.1}/pyproject.toml +1 -1
- memgit-0.9.1/tests/test_backup.py +236 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_v060.py +33 -0
- {memgit-0.8.1 → memgit-0.9.1}/LICENSE +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/cloud/__init__.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/cloud/client.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/cloud/commands.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/cloud/crypto.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/cloud/state.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/cloud/sync.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/delivery.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/evaluate.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/gitdigest.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/graph.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/hooks.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/http_server.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/importer.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/links.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/mcp_server.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/metrics.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/models.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/project.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/sanitize.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/scorer.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/store.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/tokens.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/toon.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit/usage.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit.egg-info/dependency_links.txt +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit.egg-info/entry_points.txt +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit.egg-info/requires.txt +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/memgit.egg-info/top_level.txt +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/setup.cfg +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_accrue.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_advanced.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_aliases.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_core.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_delivery.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_setup.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_store_repo.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_toon.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_v020.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_v030.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_v040.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_v070.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_v080.py +0 -0
- {memgit-0.8.1 → memgit-0.9.1}/tests/test_v081.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memgit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
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
|
|
@@ -36,6 +36,8 @@ Dynamic: license-file
|
|
|
36
36
|
<img src="assets/logo.png" alt="memgit logo" width="120" />
|
|
37
37
|
</p>
|
|
38
38
|
|
|
39
|
+
<!-- mcp-name: dev.memgit/memgit -->
|
|
40
|
+
|
|
39
41
|
# memgit — git for AI memory
|
|
40
42
|
|
|
41
43
|
**Your AI assistants forget everything when the session ends. memgit fixes that.**
|
|
@@ -44,7 +46,7 @@ Version-controlled, cross-AI context that persists, diffs, rolls back, and syncs
|
|
|
44
46
|
|
|
45
47
|
[](https://pypi.org/project/memgit/)
|
|
46
48
|
[](LICENSE)
|
|
47
|
-
[](tests/)
|
|
48
50
|
|
|
49
51
|
---
|
|
50
52
|
|
|
@@ -296,6 +298,23 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
|
|
|
296
298
|
|
|
297
299
|
---
|
|
298
300
|
|
|
301
|
+
## Backups that actually happen
|
|
302
|
+
|
|
303
|
+
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.
|
|
304
|
+
|
|
305
|
+
Since 0.9.0 it runs itself, at the end of a session, with the safety boundary drawn at **network egress rather than effort**:
|
|
306
|
+
|
|
307
|
+
- **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.
|
|
308
|
+
- **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.
|
|
309
|
+
|
|
310
|
+
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.
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
memgit backup status # where the last copy went, and what else is available
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
299
318
|
## Ranking you can prove
|
|
300
319
|
|
|
301
320
|
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 +367,12 @@ memgit core heal # self-repair a guide that has drifted
|
|
|
348
367
|
# (a project's FIRST guide is created automatically
|
|
349
368
|
# once it holds 5+ memories — no command needed)
|
|
350
369
|
|
|
370
|
+
# Durability — automatic, no human command required
|
|
371
|
+
memgit backup status # last copy, staleness, available destinations
|
|
372
|
+
memgit backup now # force one immediately
|
|
373
|
+
memgit backup set <path> # pin a destination
|
|
374
|
+
memgit backup off / on # control the automatic path
|
|
375
|
+
|
|
351
376
|
# Retrieval evaluation — prove a ranking change helped
|
|
352
377
|
memgit eval mine # freeze a regression set from real recall events
|
|
353
378
|
memgit eval mine --synthetic # non-circular set: query each memory by its own `why`
|
|
@@ -473,7 +498,7 @@ git clone https://github.com/code4161/memgit.git
|
|
|
473
498
|
cd memgit
|
|
474
499
|
python -m venv .venv && source .venv/bin/activate
|
|
475
500
|
pip install -e ".[dev]"
|
|
476
|
-
pytest #
|
|
501
|
+
pytest # 431 tests, all passing, < 5 seconds
|
|
477
502
|
```
|
|
478
503
|
|
|
479
504
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -508,6 +533,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
508
533
|
- [x] `memgit eval` — measured retrieval quality, real + non-circular sets (v0.8.0)
|
|
509
534
|
- [x] Usage-aware ranking — the recall ledger feeds relevance, not just the guide (v0.8.0)
|
|
510
535
|
- [x] Automatic core-guide bootstrap — the self-improving loop starts itself (v0.8.0)
|
|
536
|
+
- [x] Automatic off-machine backup — no human command required (v0.9.0)
|
|
511
537
|
- [ ] JetBrains plugin (Phase 3)
|
|
512
538
|
- [ ] Semantic search via embeddings — gated on `memgit eval` showing a real gain (Phase 4)
|
|
513
539
|
- [ ] Public benchmark numbers (LongMemEval, LoCoMo) (Phase 4)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
<img src="assets/logo.png" alt="memgit logo" width="120" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
+
<!-- mcp-name: dev.memgit/memgit -->
|
|
6
|
+
|
|
5
7
|
# memgit — git for AI memory
|
|
6
8
|
|
|
7
9
|
**Your AI assistants forget everything when the session ends. memgit fixes that.**
|
|
@@ -10,7 +12,7 @@ Version-controlled, cross-AI context that persists, diffs, rolls back, and syncs
|
|
|
10
12
|
|
|
11
13
|
[](https://pypi.org/project/memgit/)
|
|
12
14
|
[](LICENSE)
|
|
13
|
-
[](tests/)
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
@@ -262,6 +264,23 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
|
|
|
262
264
|
|
|
263
265
|
---
|
|
264
266
|
|
|
267
|
+
## Backups that actually happen
|
|
268
|
+
|
|
269
|
+
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.
|
|
270
|
+
|
|
271
|
+
Since 0.9.0 it runs itself, at the end of a session, with the safety boundary drawn at **network egress rather than effort**:
|
|
272
|
+
|
|
273
|
+
- **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.
|
|
274
|
+
- **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.
|
|
275
|
+
|
|
276
|
+
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.
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
memgit backup status # where the last copy went, and what else is available
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
265
284
|
## Ranking you can prove
|
|
266
285
|
|
|
267
286
|
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 +333,12 @@ memgit core heal # self-repair a guide that has drifted
|
|
|
314
333
|
# (a project's FIRST guide is created automatically
|
|
315
334
|
# once it holds 5+ memories — no command needed)
|
|
316
335
|
|
|
336
|
+
# Durability — automatic, no human command required
|
|
337
|
+
memgit backup status # last copy, staleness, available destinations
|
|
338
|
+
memgit backup now # force one immediately
|
|
339
|
+
memgit backup set <path> # pin a destination
|
|
340
|
+
memgit backup off / on # control the automatic path
|
|
341
|
+
|
|
317
342
|
# Retrieval evaluation — prove a ranking change helped
|
|
318
343
|
memgit eval mine # freeze a regression set from real recall events
|
|
319
344
|
memgit eval mine --synthetic # non-circular set: query each memory by its own `why`
|
|
@@ -439,7 +464,7 @@ git clone https://github.com/code4161/memgit.git
|
|
|
439
464
|
cd memgit
|
|
440
465
|
python -m venv .venv && source .venv/bin/activate
|
|
441
466
|
pip install -e ".[dev]"
|
|
442
|
-
pytest #
|
|
467
|
+
pytest # 431 tests, all passing, < 5 seconds
|
|
443
468
|
```
|
|
444
469
|
|
|
445
470
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -474,6 +499,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
474
499
|
- [x] `memgit eval` — measured retrieval quality, real + non-circular sets (v0.8.0)
|
|
475
500
|
- [x] Usage-aware ranking — the recall ledger feeds relevance, not just the guide (v0.8.0)
|
|
476
501
|
- [x] Automatic core-guide bootstrap — the self-improving loop starts itself (v0.8.0)
|
|
502
|
+
- [x] Automatic off-machine backup — no human command required (v0.9.0)
|
|
477
503
|
- [ ] JetBrains plugin (Phase 3)
|
|
478
504
|
- [ ] Semantic search via embeddings — gated on `memgit eval` showing a real gain (Phase 4)
|
|
479
505
|
- [ ] Public benchmark numbers (LongMemEval, LoCoMo) (Phase 4)
|
|
@@ -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):
|
|
@@ -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.
|
|
3
|
+
Version: 0.9.1
|
|
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
|
|
@@ -36,6 +36,8 @@ Dynamic: license-file
|
|
|
36
36
|
<img src="assets/logo.png" alt="memgit logo" width="120" />
|
|
37
37
|
</p>
|
|
38
38
|
|
|
39
|
+
<!-- mcp-name: dev.memgit/memgit -->
|
|
40
|
+
|
|
39
41
|
# memgit — git for AI memory
|
|
40
42
|
|
|
41
43
|
**Your AI assistants forget everything when the session ends. memgit fixes that.**
|
|
@@ -44,7 +46,7 @@ Version-controlled, cross-AI context that persists, diffs, rolls back, and syncs
|
|
|
44
46
|
|
|
45
47
|
[](https://pypi.org/project/memgit/)
|
|
46
48
|
[](LICENSE)
|
|
47
|
-
[](tests/)
|
|
48
50
|
|
|
49
51
|
---
|
|
50
52
|
|
|
@@ -296,6 +298,23 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
|
|
|
296
298
|
|
|
297
299
|
---
|
|
298
300
|
|
|
301
|
+
## Backups that actually happen
|
|
302
|
+
|
|
303
|
+
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.
|
|
304
|
+
|
|
305
|
+
Since 0.9.0 it runs itself, at the end of a session, with the safety boundary drawn at **network egress rather than effort**:
|
|
306
|
+
|
|
307
|
+
- **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.
|
|
308
|
+
- **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.
|
|
309
|
+
|
|
310
|
+
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.
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
memgit backup status # where the last copy went, and what else is available
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
299
318
|
## Ranking you can prove
|
|
300
319
|
|
|
301
320
|
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 +367,12 @@ memgit core heal # self-repair a guide that has drifted
|
|
|
348
367
|
# (a project's FIRST guide is created automatically
|
|
349
368
|
# once it holds 5+ memories — no command needed)
|
|
350
369
|
|
|
370
|
+
# Durability — automatic, no human command required
|
|
371
|
+
memgit backup status # last copy, staleness, available destinations
|
|
372
|
+
memgit backup now # force one immediately
|
|
373
|
+
memgit backup set <path> # pin a destination
|
|
374
|
+
memgit backup off / on # control the automatic path
|
|
375
|
+
|
|
351
376
|
# Retrieval evaluation — prove a ranking change helped
|
|
352
377
|
memgit eval mine # freeze a regression set from real recall events
|
|
353
378
|
memgit eval mine --synthetic # non-circular set: query each memory by its own `why`
|
|
@@ -473,7 +498,7 @@ git clone https://github.com/code4161/memgit.git
|
|
|
473
498
|
cd memgit
|
|
474
499
|
python -m venv .venv && source .venv/bin/activate
|
|
475
500
|
pip install -e ".[dev]"
|
|
476
|
-
pytest #
|
|
501
|
+
pytest # 431 tests, all passing, < 5 seconds
|
|
477
502
|
```
|
|
478
503
|
|
|
479
504
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -508,6 +533,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
508
533
|
- [x] `memgit eval` — measured retrieval quality, real + non-circular sets (v0.8.0)
|
|
509
534
|
- [x] Usage-aware ranking — the recall ledger feeds relevance, not just the guide (v0.8.0)
|
|
510
535
|
- [x] Automatic core-guide bootstrap — the self-improving loop starts itself (v0.8.0)
|
|
536
|
+
- [x] Automatic off-machine backup — no human command required (v0.9.0)
|
|
511
537
|
- [ ] JetBrains plugin (Phase 3)
|
|
512
538
|
- [ ] Semantic search via embeddings — gated on `memgit eval` showing a real gain (Phase 4)
|
|
513
539
|
- [ ] 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.
|
|
7
|
+
version = "0.9.1"
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|