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.
- {memgit-0.8.0 → memgit-0.9.0}/PKG-INFO +27 -3
- {memgit-0.8.0 → memgit-0.9.0}/README.md +26 -2
- {memgit-0.8.0 → memgit-0.9.0}/memgit/__init__.py +1 -1
- memgit-0.9.0/memgit/backup.py +377 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/cli.py +118 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/delivery.py +32 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/repo.py +9 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/PKG-INFO +27 -3
- {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/SOURCES.txt +2 -0
- {memgit-0.8.0 → memgit-0.9.0}/pyproject.toml +1 -1
- memgit-0.9.0/tests/test_backup.py +236 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_v060.py +33 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_v081.py +26 -0
- {memgit-0.8.0 → memgit-0.9.0}/LICENSE +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/__init__.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/client.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/commands.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/crypto.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/state.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/cloud/sync.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/evaluate.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/gitdigest.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/graph.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/hooks.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/http_server.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/importer.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/links.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/mcp_server.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/metrics.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/models.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/project.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/sanitize.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/scorer.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/store.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/tokens.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/toon.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit/usage.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/dependency_links.txt +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/entry_points.txt +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/requires.txt +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/memgit.egg-info/top_level.txt +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/setup.cfg +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_accrue.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_advanced.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_aliases.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_core.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_delivery.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_setup.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_store_repo.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_toon.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_v020.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_v030.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_v040.py +0 -0
- {memgit-0.8.0 → memgit-0.9.0}/tests/test_v070.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/memgit/)
|
|
46
46
|
[](LICENSE)
|
|
47
|
-
[](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 #
|
|
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
|
[](https://pypi.org/project/memgit/)
|
|
12
12
|
[](LICENSE)
|
|
13
|
-
[](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 #
|
|
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)
|
|
@@ -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.
|
|
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
|
[](https://pypi.org/project/memgit/)
|
|
46
46
|
[](LICENSE)
|
|
47
|
-
[](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 #
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|