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.
Files changed (69) hide show
  1. {memware-0.2.0 → memware-0.2.2}/CHANGELOG.md +13 -0
  2. {memware-0.2.0 → memware-0.2.2}/PKG-INFO +3 -2
  3. {memware-0.2.0 → memware-0.2.2}/README.md +2 -1
  4. {memware-0.2.0 → memware-0.2.2}/docs/backup.md +38 -11
  5. memware-0.2.2/integrations/claude-code/hooks/hooks.json +42 -0
  6. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/memware/__init__.py +26 -0
  7. {memware-0.2.0 → memware-0.2.2}/src/memware/__init__.py +1 -1
  8. {memware-0.2.0 → memware-0.2.2}/src/memware/backup.py +31 -12
  9. {memware-0.2.0 → memware-0.2.2}/src/memware/cli.py +22 -1
  10. {memware-0.2.0 → memware-0.2.2}/src/memware/config.py +2 -0
  11. {memware-0.2.0 → memware-0.2.2}/tests/test_backup.py +66 -8
  12. memware-0.2.0/integrations/claude-code/hooks/hooks.json +0 -13
  13. {memware-0.2.0 → memware-0.2.2}/.claude-plugin/marketplace.json +0 -0
  14. {memware-0.2.0 → memware-0.2.2}/.github/CODEOWNERS +0 -0
  15. {memware-0.2.0 → memware-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {memware-0.2.0 → memware-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {memware-0.2.0 → memware-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {memware-0.2.0 → memware-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {memware-0.2.0 → memware-0.2.2}/.github/dependabot.yml +0 -0
  20. {memware-0.2.0 → memware-0.2.2}/.github/workflows/ci.yml +0 -0
  21. {memware-0.2.0 → memware-0.2.2}/.github/workflows/release.yml +0 -0
  22. {memware-0.2.0 → memware-0.2.2}/.gitignore +0 -0
  23. {memware-0.2.0 → memware-0.2.2}/.pre-commit-config.yaml +0 -0
  24. {memware-0.2.0 → memware-0.2.2}/CODE_OF_CONDUCT.md +0 -0
  25. {memware-0.2.0 → memware-0.2.2}/CONTRIBUTING.md +0 -0
  26. {memware-0.2.0 → memware-0.2.2}/LICENSE +0 -0
  27. {memware-0.2.0 → memware-0.2.2}/RELEASING.md +0 -0
  28. {memware-0.2.0 → memware-0.2.2}/SECURITY.md +0 -0
  29. {memware-0.2.0 → memware-0.2.2}/docs/design.md +0 -0
  30. {memware-0.2.0 → memware-0.2.2}/docs/eval.md +0 -0
  31. {memware-0.2.0 → memware-0.2.2}/docs/integrations.md +0 -0
  32. {memware-0.2.0 → memware-0.2.2}/docs/keeping-memory-clean.md +0 -0
  33. {memware-0.2.0 → memware-0.2.2}/docs/upstream-hermes-pr.md +0 -0
  34. {memware-0.2.0 → memware-0.2.2}/eval/questions.example.jsonl +0 -0
  35. {memware-0.2.0 → memware-0.2.2}/integrations/claude-code/.claude-plugin/plugin.json +0 -0
  36. {memware-0.2.0 → memware-0.2.2}/integrations/claude-code/README.md +0 -0
  37. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/README.md +0 -0
  38. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/memware/plugin.yaml +0 -0
  39. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/README.md +0 -0
  40. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/README.md +0 -0
  41. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/__init__.py +0 -0
  42. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/config_schema.py +0 -0
  43. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/plugins/memory/memware/plugin.yaml +0 -0
  44. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/tests/plugins/memory/test_memware_provider.py +0 -0
  45. {memware-0.2.0 → memware-0.2.2}/integrations/hermes/upstream/website/docs/user-guide/features/memory-providers.memware.md +0 -0
  46. {memware-0.2.0 → memware-0.2.2}/pyproject.toml +0 -0
  47. {memware-0.2.0 → memware-0.2.2}/scripts/leak-check.sh +0 -0
  48. {memware-0.2.0 → memware-0.2.2}/src/memware/eval.py +0 -0
  49. {memware-0.2.0 → memware-0.2.2}/src/memware/index.py +0 -0
  50. {memware-0.2.0 → memware-0.2.2}/src/memware/ingest/__init__.py +0 -0
  51. {memware-0.2.0 → memware-0.2.2}/src/memware/ingest/claude_code.py +0 -0
  52. {memware-0.2.0 → memware-0.2.2}/src/memware/ingest/generic.py +0 -0
  53. {memware-0.2.0 → memware-0.2.2}/src/memware/ledger.py +0 -0
  54. {memware-0.2.0 → memware-0.2.2}/src/memware/mcp_server.py +0 -0
  55. {memware-0.2.0 → memware-0.2.2}/src/memware/passage.py +0 -0
  56. {memware-0.2.0 → memware-0.2.2}/src/memware/py.typed +0 -0
  57. {memware-0.2.0 → memware-0.2.2}/src/memware/review.py +0 -0
  58. {memware-0.2.0 → memware-0.2.2}/src/memware/store.py +0 -0
  59. {memware-0.2.0 → memware-0.2.2}/tests/__init__.py +0 -0
  60. {memware-0.2.0 → memware-0.2.2}/tests/conftest.py +0 -0
  61. {memware-0.2.0 → memware-0.2.2}/tests/test_cli.py +0 -0
  62. {memware-0.2.0 → memware-0.2.2}/tests/test_eval.py +0 -0
  63. {memware-0.2.0 → memware-0.2.2}/tests/test_hermes_plugin.py +0 -0
  64. {memware-0.2.0 → memware-0.2.2}/tests/test_hermes_upstream_plugin.py +0 -0
  65. {memware-0.2.0 → memware-0.2.2}/tests/test_index.py +0 -0
  66. {memware-0.2.0 → memware-0.2.2}/tests/test_ingest.py +0 -0
  67. {memware-0.2.0 → memware-0.2.2}/tests/test_ledger.py +0 -0
  68. {memware-0.2.0 → memware-0.2.2}/tests/test_passage.py +0 -0
  69. {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.0
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** keeps the newest snapshot plus the newest one at least 1, 3, 7 and 14 days old —
39
- roughly a 1-, 3-, 7- and 14-day-old backup at all times, not an unbounded pile. Tune with
40
- `backup.keep_days`.
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
- ## Schedule it
48
-
49
- Any scheduler works; memware just needs to run `memware backup` daily. macOS `launchd`, Linux
50
- `cron`, or a coding-agent cron all do. Example (cron, 3 a.m.):
51
-
52
- ```cron
53
- 0 3 * * * /path/to/memware backup >/dev/null 2>&1
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)
@@ -22,4 +22,4 @@ __all__ = [
22
22
  "history",
23
23
  "reject",
24
24
  ]
25
- __version__ = "0.2.0"
25
+ __version__ = "0.2.2"
@@ -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 apply_retention(dest_dir: str | os.PathLike[str], keep_days: list[int]) -> list[Path]:
58
- """Tiered retention. Always keep the newest snapshot; then for each age bucket in
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
- keep: set[Path] = {snaps[0]} # newest always
67
- for days in sorted(set(keep_days)):
68
- cutoff = now.timestamp() - days * 86400
69
- aged = [p for p in snaps if _stamp(p).timestamp() <= cutoff]
70
- if aged:
71
- keep.add(aged[0]) # newest that is at least `days` old
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
- _out(result, a.json)
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 test_tiered_retention_keeps_one_per_bucket(tmp_path):
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
- now = dt.datetime.now(dt.UTC)
40
- # one snapshot per day for 20 days
41
- for age in range(20):
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
- kept = bk.list_snapshots(dest)
46
- # newest + one each at >=1,>=3,>=7,>=14 days = at most 5, far fewer than 20
47
- assert 4 <= len(kept) <= 5
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
File without changes
File without changes
File without changes