memware 0.2.0__tar.gz → 0.2.2__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.
- {memware-0.2.0 → memware-0.2.2}/CHANGELOG.md +13 -0
- {memware-0.2.0 → memware-0.2.2}/PKG-INFO +3 -2
- {memware-0.2.0 → memware-0.2.2}/README.md +2 -1
- {memware-0.2.0 → memware-0.2.2}/docs/backup.md +38 -11
- memware-0.2.2/integrations/claude-code/hooks/hooks.json +42 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/memware/__init__.py +26 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/__init__.py +1 -1
- {memware-0.2.0 → memware-0.2.2}/src/memware/backup.py +31 -12
- {memware-0.2.0 → memware-0.2.2}/src/memware/cli.py +22 -1
- {memware-0.2.0 → memware-0.2.2}/src/memware/config.py +2 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_backup.py +66 -8
- memware-0.2.0/integrations/claude-code/hooks/hooks.json +0 -13
- {memware-0.2.0 → memware-0.2.2}/.claude-plugin/marketplace.json +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/CODEOWNERS +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/dependabot.yml +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/workflows/ci.yml +0 -0
- {memware-0.2.0 → memware-0.2.2}/.github/workflows/release.yml +0 -0
- {memware-0.2.0 → memware-0.2.2}/.gitignore +0 -0
- {memware-0.2.0 → memware-0.2.2}/.pre-commit-config.yaml +0 -0
- {memware-0.2.0 → memware-0.2.2}/CODE_OF_CONDUCT.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/CONTRIBUTING.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/LICENSE +0 -0
- {memware-0.2.0 → memware-0.2.2}/RELEASING.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/SECURITY.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/docs/design.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/docs/eval.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/docs/integrations.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/docs/keeping-memory-clean.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/docs/upstream-hermes-pr.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/eval/questions.example.jsonl +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/claude-code/.claude-plugin/plugin.json +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/claude-code/README.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/README.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/memware/plugin.yaml +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/README.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/README.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/__init__.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/config_schema.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/plugin.yaml +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/tests/plugins/memory/test_memware_provider.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/website/docs/user-guide/features/memory-providers.memware.md +0 -0
- {memware-0.2.0 → memware-0.2.2}/pyproject.toml +0 -0
- {memware-0.2.0 → memware-0.2.2}/scripts/leak-check.sh +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/eval.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/index.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/ingest/__init__.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/ingest/claude_code.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/ingest/generic.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/ledger.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/mcp_server.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/passage.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/py.typed +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/review.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/src/memware/store.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/__init__.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/conftest.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_cli.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_eval.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_hermes_plugin.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_hermes_upstream_plugin.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_index.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_ingest.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_ledger.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_passage.py +0 -0
- {memware-0.2.0 → memware-0.2.2}/tests/test_review.py +0 -0
|
@@ -6,9 +6,22 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] - 2026-09-03
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Backup retention now **promotes**: a snapshot ages forward through the 1/3/7/14-day tiers
|
|
13
|
+
instead of being pruned between them. The previous rule pruned every snapshot at age ~2, so a
|
|
14
|
+
daily-backup loop only ever kept a 0- and 1-day-old and never built a 3/7/14-day-old. Retention
|
|
15
|
+
keeps the newest plus the oldest per age band, always has a fresh ~1-day-old, drifts within
|
|
16
|
+
older tiers, prunes past the largest tier, and stays bounded (~5). Verified with a 30-day loop.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [0.2.1] - 2026-09-03
|
|
20
|
+
|
|
9
21
|
## [0.2.0] - 2026-09-03
|
|
10
22
|
|
|
11
23
|
### Added
|
|
24
|
+
- Automatic backups with no scheduler: the Claude Code plugin and Hermes provider run a throttled `memware backup --if-stale` at session end (config `backup.auto`, `backup.auto_interval_hours`), so backups ride usage and survive a sleeping laptop. `--if-stale HOURS`/`--quiet` make `backup` safe to call from any hook or cron.
|
|
12
25
|
- Backups: `memware backup` writes a consistent snapshot (VACUUM INTO) to a storage-agnostic
|
|
13
26
|
folder (Dropbox/iCloud/Drive/external disk) with tiered retention (newest + 1/3/7/14-day),
|
|
14
27
|
and mirrors transcripts into `<dest>/transcripts` so they outlive the OS's 30-day cleanup.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memware
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Memory for AI agents that only remembers the latest truth: a bi-temporal belief ledger plus a transcript index, in one SQLite file.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ericwalisko/memware
|
|
6
6
|
Project-URL: Issues, https://github.com/ericwalisko/memware/issues
|
|
@@ -172,7 +172,8 @@ Prompt-time injection (the hooks) stays deterministic and only injects beliefs w
|
|
|
172
172
|
Transcripts are deleted by the OS after ~30 days, so an aged session lives only in the store —
|
|
173
173
|
**back it up, and never wipe-and-re-backfill** (backfill only re-indexes transcripts still on
|
|
174
174
|
disk). memware guards this: migrations snapshot first, and `backfill` warns if a backup is
|
|
175
|
-
larger than the store.
|
|
175
|
+
larger than the store. Once a destination is set, backups happen **automatically at session
|
|
176
|
+
end (~once a day)** — no cron, and immune to a laptop sleeping through a scheduled time.
|
|
176
177
|
|
|
177
178
|
```bash
|
|
178
179
|
memware setup # pick a folder: Dropbox / iCloud / Drive / external disk
|
|
@@ -141,7 +141,8 @@ Prompt-time injection (the hooks) stays deterministic and only injects beliefs w
|
|
|
141
141
|
Transcripts are deleted by the OS after ~30 days, so an aged session lives only in the store —
|
|
142
142
|
**back it up, and never wipe-and-re-backfill** (backfill only re-indexes transcripts still on
|
|
143
143
|
disk). memware guards this: migrations snapshot first, and `backfill` warns if a backup is
|
|
144
|
-
larger than the store.
|
|
144
|
+
larger than the store. Once a destination is set, backups happen **automatically at session
|
|
145
|
+
end (~once a day)** — no cron, and immune to a laptop sleeping through a scheduled time.
|
|
145
146
|
|
|
146
147
|
```bash
|
|
147
148
|
memware setup # pick a folder: Dropbox / iCloud / Drive / external disk
|
|
@@ -35,23 +35,50 @@ memware backup # writes memware-YYYYMMDD-HHMMSS.db, prunes to the retent
|
|
|
35
35
|
# and (by default) mirrors transcripts into <dest>/transcripts
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
**Retention
|
|
39
|
-
|
|
40
|
-
`
|
|
38
|
+
**Retention promotes.** Because memware only ever *creates* fresh snapshots, retention lets a
|
|
39
|
+
snapshot **age forward** through the tiers rather than pruning it in between: it keeps the newest
|
|
40
|
+
snapshot and the oldest one in each age band `(0,1] (1,3] (3,7] (7,14]`, so the same file serves
|
|
41
|
+
as the ~1-day-old, then the ~3-, ~7-, ~14-day-old as time passes. You always have a fresh
|
|
42
|
+
~1-day-old; the older tiers drift a little (that is fine and expected); anything past the largest
|
|
43
|
+
tier is pruned. The pile stays bounded (~5). Tune the tiers with `backup.keep_days`.
|
|
41
44
|
|
|
42
45
|
**Transcripts.** `backup.include_transcripts` (on by default) mirrors your transcript source
|
|
43
46
|
(default `~/.claude/projects`) into `<dest>/transcripts`, additively — an append-only archive
|
|
44
47
|
that outlives the 30-day cleanup. Already back transcripts up elsewhere? Point memware at that
|
|
45
48
|
location with `backup.transcript_src` and it will read from there too.
|
|
46
49
|
|
|
47
|
-
##
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
## How it stays current on its own (no scheduler)
|
|
51
|
+
|
|
52
|
+
Once a backup destination is set (`memware setup`), the Claude Code plugin and the Hermes
|
|
53
|
+
provider back up **automatically at session end, at most once per `backup.auto_interval_hours`
|
|
54
|
+
(default 20)**. This rides your usage rather than a clock, so it is immune to the sleep problem:
|
|
55
|
+
a laptop only needs to be awake *while you are using it*, which it is. Nothing to schedule, and
|
|
56
|
+
a missed day simply catches up on your next session. Turn it off with
|
|
57
|
+
`memware config backup.auto false`.
|
|
58
|
+
|
|
59
|
+
Both the snapshot and the transcript mirror happen in that one throttled run, so a laptop user
|
|
60
|
+
who never sets up a cron still gets: the store snapshotted off-machine, and transcripts archived
|
|
61
|
+
before the OS's 30-day cleanup can reach them.
|
|
62
|
+
|
|
63
|
+
## Scheduling on an always-on machine (optional)
|
|
64
|
+
|
|
65
|
+
If a box is always on and you want backups on a fixed clock regardless of whether anyone used
|
|
66
|
+
it that day, add a scheduled job. Pick the one that fits — and note that **plain `cron` skips
|
|
67
|
+
runs that fall while the machine is asleep or off**, so it is the wrong choice for a laptop:
|
|
68
|
+
|
|
69
|
+
- **macOS — `launchd`** (sleep-tolerant: a missed `StartCalendarInterval` runs on wake):
|
|
70
|
+
a `~/Library/LaunchAgents/*.plist` with `StartCalendarInterval` (hour 3) calling
|
|
71
|
+
`memware backup`.
|
|
72
|
+
- **Linux — `systemd` timer** with `Persistent=true` (runs a missed timer on next boot/wake):
|
|
73
|
+
a `memware-backup.timer` + `.service` pair, `OnCalendar=daily`.
|
|
74
|
+
- **Linux — `anacron`** if the machine is often off: a daily job that runs when the box is next
|
|
75
|
+
up. Plain `cron` does not do this.
|
|
76
|
+
- **Coding-agent cron** (e.g. a Hermes `--no-agent` job): fine on an always-on host; same sleep
|
|
77
|
+
caveat as `cron`.
|
|
78
|
+
|
|
79
|
+
`memware backup --if-stale HOURS` is safe to call from any of these too — it no-ops if a recent
|
|
80
|
+
snapshot already exists, so the automatic session-end backup and a scheduled one never
|
|
81
|
+
double up.
|
|
55
82
|
|
|
56
83
|
## Restore
|
|
57
84
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionEnd": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "memware sync --harness claude-code --from-hook",
|
|
9
|
+
"timeout": 30
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "memware backup --if-stale 20 --quiet",
|
|
14
|
+
"timeout": 120
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"PreCompact": [
|
|
20
|
+
{
|
|
21
|
+
"hooks": [
|
|
22
|
+
{
|
|
23
|
+
"type": "command",
|
|
24
|
+
"command": "memware sync --harness claude-code --from-hook",
|
|
25
|
+
"timeout": 30
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"UserPromptSubmit": [
|
|
31
|
+
{
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "memware context --from-hook -k 6",
|
|
36
|
+
"timeout": 10
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -266,6 +266,31 @@ class MemwareProvider(MemoryProvider):
|
|
|
266
266
|
self._sync_thread = threading.Thread(target=_run, daemon=True)
|
|
267
267
|
self._sync_thread.start()
|
|
268
268
|
|
|
269
|
+
def _auto_backup(self) -> None:
|
|
270
|
+
"""Ride usage: after a session, snapshot at most once per configured interval when a
|
|
271
|
+
backup dest is set. No cron needed — the machine only needs to be on while in use."""
|
|
272
|
+
try:
|
|
273
|
+
from memware import backup as bk
|
|
274
|
+
from memware.config import get_dotted, load_config
|
|
275
|
+
|
|
276
|
+
cfg = load_config()
|
|
277
|
+
dest = get_dotted(cfg, "backup.dest")
|
|
278
|
+
if not dest or not get_dotted(cfg, "backup.auto"):
|
|
279
|
+
return
|
|
280
|
+
interval = float(get_dotted(cfg, "backup.auto_interval_hours") or 20)
|
|
281
|
+
age = bk.newest_age_hours(dest)
|
|
282
|
+
if age is not None and age < interval:
|
|
283
|
+
return
|
|
284
|
+
keep = get_dotted(cfg, "backup.keep_days") or [1, 3, 7, 14]
|
|
285
|
+
bk.snapshot(self._db, dest)
|
|
286
|
+
bk.apply_retention(dest, keep)
|
|
287
|
+
if get_dotted(cfg, "backup.include_transcripts"):
|
|
288
|
+
bk.mirror_transcripts(
|
|
289
|
+
get_dotted(cfg, "backup.transcript_src") or "~/.claude/projects", dest
|
|
290
|
+
)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.warning("memware auto-backup failed: %s", e)
|
|
293
|
+
|
|
269
294
|
def on_session_end(self, messages: list[dict[str, Any]], **kwargs: Any) -> None:
|
|
270
295
|
self.shutdown()
|
|
271
296
|
# turns were captured incrementally; re-sync the file once for anything missed
|
|
@@ -279,6 +304,7 @@ class MemwareProvider(MemoryProvider):
|
|
|
279
304
|
sync_file(s, path, harness="generic")
|
|
280
305
|
except Exception as e:
|
|
281
306
|
logger.warning("memware on_session_end failed: %s", e)
|
|
307
|
+
self._auto_backup()
|
|
282
308
|
|
|
283
309
|
def on_pre_compress(self, messages: list[dict[str, Any]], **kwargs: Any) -> None:
|
|
284
310
|
self.on_session_end(messages, **kwargs)
|
|
@@ -54,24 +54,43 @@ def _stamp(p: Path) -> datetime:
|
|
|
54
54
|
return datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(tzinfo=UTC)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def
|
|
58
|
-
"""
|
|
59
|
-
``keep_days`` (e.g. [1,3,7,14]) keep the newest snapshot at least that old. Delete the
|
|
60
|
-
rest. Returns the deleted paths. This yields, over time, roughly one 1-, 3-, 7- and
|
|
61
|
-
14-day-old backup plus the latest — not an unbounded pile."""
|
|
57
|
+
def newest_age_hours(dest_dir: str | os.PathLike[str]) -> float | None:
|
|
58
|
+
"""Hours since the newest snapshot in ``dest_dir``, or None if there are none."""
|
|
62
59
|
snaps = list_snapshots(dest_dir)
|
|
60
|
+
if not snaps:
|
|
61
|
+
return None
|
|
62
|
+
return (_now().timestamp() - _stamp(snaps[0]).timestamp()) / 3600.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _age_days(p: Path, now: datetime) -> float:
|
|
66
|
+
return (now.timestamp() - _stamp(p).timestamp()) / 86400.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_retention(dest_dir: str | os.PathLike[str], keep_days: list[int]) -> list[Path]:
|
|
70
|
+
"""Promotion retention: since we only ever *create* fresh snapshots, a snapshot must be
|
|
71
|
+
allowed to **age forward** into the next tier rather than be pruned between tiers.
|
|
72
|
+
|
|
73
|
+
Bands are the contiguous intervals ``(0, d0], (d0, d1], …`` for sorted ``keep_days``
|
|
74
|
+
(default 1,3,7,14). We keep the newest snapshot overall (tomorrow's ~1-day-old) and the
|
|
75
|
+
*oldest* snapshot in each band — the oldest is the one on the leading edge, so as days
|
|
76
|
+
pass it crosses into the next band and stays kept, i.e. the same file serves as the ~1-,
|
|
77
|
+
then ~3-, ~7-, ~14-day-old. Everything else, and anything older than the largest tier,
|
|
78
|
+
is pruned. Result: always a ~1-day-old, roughly one per tier (they drift; that's fine),
|
|
79
|
+
and a bounded pile of ~len(keep_days)+1. Returns the deleted paths."""
|
|
80
|
+
snaps = list_snapshots(dest_dir) # newest first
|
|
63
81
|
if not snaps:
|
|
64
82
|
return []
|
|
65
83
|
now = _now()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if
|
|
71
|
-
|
|
84
|
+
days = sorted(set(int(d) for d in keep_days))
|
|
85
|
+
keep: set[Path] = {snaps[0]} # always the freshest
|
|
86
|
+
edges = [0.0, *[float(d) for d in days]]
|
|
87
|
+
for lo, hi in zip(edges, edges[1:], strict=False):
|
|
88
|
+
band = [p for p in snaps if lo < _age_days(p, now) <= hi]
|
|
89
|
+
if band:
|
|
90
|
+
keep.add(band[-1]) # oldest in the band (snaps are newest-first) -> promotes forward
|
|
72
91
|
deleted = []
|
|
73
92
|
for p in snaps:
|
|
74
|
-
if p not in keep:
|
|
93
|
+
if p not in keep: # includes anything older than the largest tier
|
|
75
94
|
p.unlink(missing_ok=True)
|
|
76
95
|
deleted.append(p)
|
|
77
96
|
return deleted
|
|
@@ -286,12 +286,24 @@ def cmd_backup(a: argparse.Namespace) -> int:
|
|
|
286
286
|
cfg = load_config()
|
|
287
287
|
dest = a.dest or get_dotted(cfg, "backup.dest")
|
|
288
288
|
if not dest:
|
|
289
|
+
if a.if_stale is not None:
|
|
290
|
+
return 0 # hook-driven: no destination configured yet, stay silent
|
|
289
291
|
print(
|
|
290
292
|
"no backup destination — pass --dest DIR or run `memware setup` "
|
|
291
293
|
"(point it at a Dropbox/iCloud/Drive folder or an external disk)",
|
|
292
294
|
file=sys.stderr,
|
|
293
295
|
)
|
|
294
296
|
return 2
|
|
297
|
+
if a.if_stale is not None:
|
|
298
|
+
# throttle: skip if a snapshot already exists within the window (so this is safe to
|
|
299
|
+
# call from every session-end hook — the backup rides usage, not a clock).
|
|
300
|
+
if not get_dotted(cfg, "backup.auto") and not a.dest:
|
|
301
|
+
return 0
|
|
302
|
+
age = bk.newest_age_hours(dest)
|
|
303
|
+
if age is not None and age < a.if_stale:
|
|
304
|
+
if not a.quiet:
|
|
305
|
+
_out({"skipped": "recent", "age_hours": round(age, 1)}, a.json)
|
|
306
|
+
return 0
|
|
295
307
|
keep = a.keep or get_dotted(cfg, "backup.keep_days") or [1, 3, 7, 14]
|
|
296
308
|
out = bk.snapshot(a.db, dest)
|
|
297
309
|
deleted = bk.apply_retention(dest, keep)
|
|
@@ -306,7 +318,8 @@ def cmd_backup(a: argparse.Namespace) -> int:
|
|
|
306
318
|
if include:
|
|
307
319
|
src = a.transcript_src or get_dotted(cfg, "backup.transcript_src") or "~/.claude/projects"
|
|
308
320
|
result["transcripts_mirrored"] = bk.mirror_transcripts(src, dest)
|
|
309
|
-
|
|
321
|
+
if not a.quiet:
|
|
322
|
+
_out(result, a.json)
|
|
310
323
|
return 0
|
|
311
324
|
|
|
312
325
|
|
|
@@ -563,6 +576,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
563
576
|
)
|
|
564
577
|
s.add_argument("--no-transcripts", dest="transcripts", action="store_false")
|
|
565
578
|
s.add_argument("--transcript-src", metavar="DIR")
|
|
579
|
+
s.add_argument(
|
|
580
|
+
"--if-stale",
|
|
581
|
+
type=float,
|
|
582
|
+
metavar="HOURS",
|
|
583
|
+
help="only back up if the newest snapshot is older than HOURS "
|
|
584
|
+
"(safe to call from every session-end hook; no-op when no dest is set)",
|
|
585
|
+
)
|
|
586
|
+
s.add_argument("--quiet", action="store_true", help="print nothing on success")
|
|
566
587
|
s.set_defaults(fn=cmd_backup)
|
|
567
588
|
|
|
568
589
|
s = add("restore", "replace the store with a snapshot (the current store is saved aside first)")
|
|
@@ -28,6 +28,8 @@ DEFAULTS: dict[str, Any] = {
|
|
|
28
28
|
"keep_days": [1, 3, 7, 14], # tiered retention: newest snapshot per age bucket
|
|
29
29
|
"include_transcripts": True, # also mirror the transcript source into <dest>/transcripts
|
|
30
30
|
"transcript_src": "~/.claude/projects", # what to mirror / where backfill also reads
|
|
31
|
+
"auto": True, # when a dest is set, the session-end hook backs up ~once/day (no cron needed)
|
|
32
|
+
"auto_interval_hours": 20, # minimum gap between automatic backups
|
|
31
33
|
},
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -31,20 +31,60 @@ def test_snapshot_is_consistent_and_restore_round_trips(tmp_path):
|
|
|
31
31
|
assert s.stats()["turns"] == 5
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def
|
|
34
|
+
def _age_all(dest):
|
|
35
|
+
"""Rename every snapshot one day older — how we advance the clock against the real _now()."""
|
|
36
|
+
import datetime as dt
|
|
37
|
+
|
|
38
|
+
for p in sorted(dest.glob("memware-*.db")):
|
|
39
|
+
stamp = bk._stamp(p) - dt.timedelta(days=1)
|
|
40
|
+
p.rename(dest / f"memware-{stamp.strftime('%Y%m%d-%H%M%S')}.db")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ages(dest):
|
|
44
|
+
import datetime as dt
|
|
45
|
+
|
|
46
|
+
now = dt.datetime.now(dt.UTC)
|
|
47
|
+
return sorted((now - bk._stamp(p)).total_seconds() / 86400.0 for p in bk.list_snapshots(dest))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_retention_promotes_and_stays_bounded_over_a_daily_loop(tmp_path):
|
|
51
|
+
"""The real usage pattern: one new snapshot per day, retention each day. A snapshot must age
|
|
52
|
+
forward through the tiers (not be pruned between them), there is always a ~1-day-old, the pile
|
|
53
|
+
stays small, and nothing lingers far past the largest tier. Files are dated in real time and
|
|
54
|
+
aged by renaming, so this exercises the true `_now()`-based logic."""
|
|
35
55
|
import datetime as dt
|
|
36
56
|
|
|
37
57
|
dest = tmp_path / "b"
|
|
38
58
|
dest.mkdir()
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
stamp = (now - dt.timedelta(days=age)).strftime("%Y%m%d-%H%M%S")
|
|
59
|
+
for _day in range(30):
|
|
60
|
+
_age_all(dest) # yesterday's snapshots become a day older
|
|
61
|
+
stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%d-%H%M%S") # a fresh age-0 today
|
|
43
62
|
(dest / f"memware-{stamp}.db").write_bytes(b"x")
|
|
63
|
+
bk.apply_retention(dest, [1, 3, 7, 14])
|
|
64
|
+
|
|
65
|
+
ages = _ages(dest)
|
|
66
|
+
assert min(ages) < 1 # always a fresh ~1-day-old
|
|
67
|
+
assert len(ages) <= 6 # bounded, not an unbounded pile
|
|
68
|
+
assert max(ages) <= 15 # nothing lingers far past the largest tier
|
|
69
|
+
assert any(2 <= a <= 9 for a in ages) # a snapshot promoted into the mid tiers (drift ok)
|
|
70
|
+
assert any(a >= 10 for a in ages) # and one aged into the oldest tier
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_retention_prunes_snapshots_older_than_the_largest_tier(tmp_path):
|
|
74
|
+
dest = tmp_path / "b"
|
|
75
|
+
dest.mkdir()
|
|
76
|
+
for age in (0, 2, 6, 13, 20, 40): # 20 and 40 are past the 14-day tier
|
|
77
|
+
_write_dated(dest, age)
|
|
44
78
|
bk.apply_retention(dest, [1, 3, 7, 14])
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
79
|
+
assert all(a <= 15 for a in _ages(dest))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write_dated(dest, age_days):
|
|
83
|
+
import datetime as dt
|
|
84
|
+
|
|
85
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
stamp = (dt.datetime.now(dt.UTC) - dt.timedelta(days=age_days)).strftime("%Y%m%d-%H%M%S")
|
|
87
|
+
(dest / f"memware-{stamp}.db").write_bytes(b"x")
|
|
48
88
|
|
|
49
89
|
|
|
50
90
|
def test_backup_cli_uses_config_dest_and_mirrors_transcripts(tmp_path, capsys, monkeypatch):
|
|
@@ -100,3 +140,21 @@ def test_backfill_warns_when_backup_is_larger(tmp_path, capsys, monkeypatch):
|
|
|
100
140
|
main(["--db", str(db), "backfill", str(proj)])
|
|
101
141
|
err = capsys.readouterr().err
|
|
102
142
|
assert "restore" in err and "400" in err
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_if_stale_throttles_and_no_dest_is_silent_noop(tmp_path, capsys, monkeypatch):
|
|
146
|
+
monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "home"))
|
|
147
|
+
db = _seed(tmp_path / "m.db", 2)
|
|
148
|
+
# no dest configured yet: --if-stale is a silent no-op (safe from a hook)
|
|
149
|
+
assert main(["--db", str(db), "backup", "--if-stale", "20", "--quiet"]) == 0
|
|
150
|
+
assert capsys.readouterr().out == ""
|
|
151
|
+
dest = tmp_path / "bk"
|
|
152
|
+
main(["--db", str(db), "config", "backup.dest", str(dest)])
|
|
153
|
+
capsys.readouterr()
|
|
154
|
+
# first stale-check has no snapshot yet -> it backs up
|
|
155
|
+
assert main(["--db", str(db), "backup", "--if-stale", "20", "--no-transcripts", "--json"]) == 0
|
|
156
|
+
n1 = len(bk.list_snapshots(dest))
|
|
157
|
+
assert n1 == 1
|
|
158
|
+
# immediate second call is within the window -> skipped, no new snapshot
|
|
159
|
+
assert main(["--db", str(db), "backup", "--if-stale", "20", "--no-transcripts", "--json"]) == 0
|
|
160
|
+
assert len(bk.list_snapshots(dest)) == n1
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"hooks": {
|
|
3
|
-
"SessionEnd": [
|
|
4
|
-
{ "hooks": [ { "type": "command", "command": "memware sync --harness claude-code --from-hook", "timeout": 30 } ] }
|
|
5
|
-
],
|
|
6
|
-
"PreCompact": [
|
|
7
|
-
{ "hooks": [ { "type": "command", "command": "memware sync --harness claude-code --from-hook", "timeout": 30 } ] }
|
|
8
|
-
],
|
|
9
|
-
"UserPromptSubmit": [
|
|
10
|
-
{ "hooks": [ { "type": "command", "command": "memware context --from-hook -k 6", "timeout": 10 } ] }
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
}
|
|
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
|
{memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/README.md
RENAMED
|
File without changes
|
{memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/__init__.py
RENAMED
|
File without changes
|
{memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/config_schema.py
RENAMED
|
File without changes
|
{memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/plugin.yaml
RENAMED
|
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
|