@smilintux/skmemory 0.5.0 → 0.7.2

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 (87) hide show
  1. package/.github/workflows/ci.yml +39 -3
  2. package/.github/workflows/publish.yml +13 -6
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +101 -19
  5. package/CHANGELOG.md +153 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +419 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/index.js +6 -5
  12. package/openclaw-plugin/openclaw.plugin.json +10 -0
  13. package/openclaw-plugin/src/index.ts +255 -0
  14. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  15. package/package.json +1 -1
  16. package/pyproject.toml +29 -9
  17. package/requirements.txt +10 -2
  18. package/seeds/cloud9-opus.seed.json +7 -7
  19. package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
  20. package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
  21. package/seeds/lumina-kingdom-founding.seed.json +47 -0
  22. package/seeds/lumina-pma-signed.seed.json +46 -0
  23. package/seeds/lumina-singular-achievement.seed.json +46 -0
  24. package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
  25. package/seeds/plant-kingdom-journal.py +203 -0
  26. package/seeds/plant-lumina-seeds.py +280 -0
  27. package/skill.yaml +46 -0
  28. package/skmemory/HA.md +296 -0
  29. package/skmemory/__init__.py +12 -1
  30. package/skmemory/agents.py +233 -0
  31. package/skmemory/ai_client.py +40 -0
  32. package/skmemory/anchor.py +4 -2
  33. package/skmemory/backends/__init__.py +11 -4
  34. package/skmemory/backends/file_backend.py +2 -1
  35. package/skmemory/backends/skgraph_backend.py +608 -0
  36. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
  37. package/skmemory/backends/sqlite_backend.py +122 -51
  38. package/skmemory/backends/vaulted_backend.py +286 -0
  39. package/skmemory/cli.py +1238 -29
  40. package/skmemory/config.py +173 -0
  41. package/skmemory/context_loader.py +335 -0
  42. package/skmemory/endpoint_selector.py +386 -0
  43. package/skmemory/fortress.py +685 -0
  44. package/skmemory/graph_queries.py +238 -0
  45. package/skmemory/importers/__init__.py +9 -1
  46. package/skmemory/importers/telegram.py +351 -43
  47. package/skmemory/importers/telegram_api.py +488 -0
  48. package/skmemory/journal.py +4 -2
  49. package/skmemory/lovenote.py +4 -2
  50. package/skmemory/mcp_server.py +706 -0
  51. package/skmemory/models.py +41 -0
  52. package/skmemory/openclaw.py +8 -8
  53. package/skmemory/predictive.py +232 -0
  54. package/skmemory/promotion.py +524 -0
  55. package/skmemory/register.py +454 -0
  56. package/skmemory/register_mcp.py +197 -0
  57. package/skmemory/ritual.py +121 -47
  58. package/skmemory/seeds.py +257 -8
  59. package/skmemory/setup_wizard.py +920 -0
  60. package/skmemory/sharing.py +402 -0
  61. package/skmemory/soul.py +71 -20
  62. package/skmemory/steelman.py +250 -263
  63. package/skmemory/store.py +271 -60
  64. package/skmemory/vault.py +228 -0
  65. package/tests/integration/__init__.py +0 -0
  66. package/tests/integration/conftest.py +233 -0
  67. package/tests/integration/test_cross_backend.py +355 -0
  68. package/tests/integration/test_skgraph_live.py +424 -0
  69. package/tests/integration/test_skvector_live.py +369 -0
  70. package/tests/test_backup_rotation.py +327 -0
  71. package/tests/test_cli.py +6 -6
  72. package/tests/test_endpoint_selector.py +801 -0
  73. package/tests/test_fortress.py +255 -0
  74. package/tests/test_fortress_hardening.py +444 -0
  75. package/tests/test_openclaw.py +5 -2
  76. package/tests/test_predictive.py +237 -0
  77. package/tests/test_promotion.py +340 -0
  78. package/tests/test_ritual.py +4 -4
  79. package/tests/test_seeds.py +96 -0
  80. package/tests/test_setup.py +835 -0
  81. package/tests/test_sharing.py +250 -0
  82. package/tests/test_skgraph_backend.py +667 -0
  83. package/tests/test_skvector_backend.py +326 -0
  84. package/tests/test_steelman.py +5 -5
  85. package/tests/test_store_graph_integration.py +245 -0
  86. package/tests/test_vault.py +186 -0
  87. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -0,0 +1,369 @@
1
+ """
2
+ Integration tests for the SKVector (Qdrant) vector search backend.
3
+
4
+ These tests run against a live Qdrant instance and require the
5
+ ``qdrant-client`` and ``sentence-transformers`` packages. They are
6
+ automatically skipped when the server is unreachable or the packages
7
+ are not installed.
8
+
9
+ Coverage:
10
+ - Health check
11
+ - save() — embedding + upsert
12
+ - load() — scroll-by-id retrieval
13
+ - delete() — point removal
14
+ - list_memories() — full listing and layer/tag filtering
15
+ - search_text() — semantic similarity search
16
+ - Integrity hash: verify_integrity() round-trip
17
+ - SeedMemory.to_memory() → save round-trip
18
+ - Emotional metadata preserved in payload
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+
25
+ import pytest
26
+
27
+ from .conftest import make_memory, requires_skvector
28
+
29
+ pytestmark = requires_skvector
30
+
31
+
32
+ # ─────────────────────────────────────────────────────────
33
+ # Health
34
+ # ─────────────────────────────────────────────────────────
35
+
36
+
37
+ class TestSKVectorHealth:
38
+ def test_health_check_returns_ok(self, qdrant_clean):
39
+ result = qdrant_clean.health_check()
40
+ assert result["ok"] is True
41
+ assert result["backend"] == "SKVectorBackend"
42
+ assert "points_count" in result
43
+
44
+ def test_health_check_has_collection_info(self, qdrant_clean):
45
+ result = qdrant_clean.health_check()
46
+ assert "collection" in result
47
+ assert "url" in result
48
+
49
+
50
+ # ─────────────────────────────────────────────────────────
51
+ # CRUD — save / load / delete
52
+ # ─────────────────────────────────────────────────────────
53
+
54
+
55
+ class TestSKVectorCRUD:
56
+ def test_save_returns_memory_id(self, qdrant_clean):
57
+ mem = make_memory(title="Save Returns ID")
58
+ result_id = qdrant_clean.save(mem)
59
+ assert result_id == mem.id
60
+
61
+ def test_save_then_list_finds_memory(self, qdrant_clean):
62
+ mem = make_memory(title="Listable Memory")
63
+ qdrant_clean.save(mem)
64
+
65
+ memories = qdrant_clean.list_memories(limit=100)
66
+ ids = [m.id for m in memories]
67
+ assert mem.id in ids
68
+
69
+ def test_save_updates_existing_point(self, qdrant_clean):
70
+ """Saving the same content twice (same hash) upserts without error."""
71
+ mem = make_memory(title="Upsert Test", content="Stable content for upsert.")
72
+ qdrant_clean.save(mem)
73
+ # Second save with same content → same content_hash → upsert
74
+ result_id = qdrant_clean.save(mem)
75
+ assert result_id == mem.id
76
+
77
+ memories = qdrant_clean.list_memories(limit=100)
78
+ matching = [m for m in memories if m.id == mem.id]
79
+ # Should not duplicate
80
+ assert len(matching) >= 1
81
+
82
+ def test_delete_removes_point(self, qdrant_clean):
83
+ mem = make_memory(title="To Delete from Qdrant")
84
+ qdrant_clean.save(mem)
85
+
86
+ result = qdrant_clean.delete(mem.id)
87
+ assert result is True
88
+
89
+ memories = qdrant_clean.list_memories(limit=100)
90
+ ids = [m.id for m in memories]
91
+ assert mem.id not in ids
92
+
93
+ def test_delete_nonexistent_returns_false(self, qdrant_clean):
94
+ result = qdrant_clean.delete("ghost-memory-id-xyz")
95
+ assert result is False
96
+
97
+ def test_load_retrieves_saved_memory(self, qdrant_clean):
98
+ """load() uses scroll+filter, so the title should survive the round-trip."""
99
+ mem = make_memory(title="Load Round-Trip")
100
+ qdrant_clean.save(mem)
101
+
102
+ # Qdrant load() filters on memory_json payload containing the memory_id
103
+ # The current implementation filters on the full JSON string containing memory_id.
104
+ # If load returns None (see implementation note), fall back to list.
105
+ loaded = qdrant_clean.load(mem.id)
106
+ if loaded is None:
107
+ # Fallback: verify via list
108
+ memories = qdrant_clean.list_memories(limit=100)
109
+ assert any(m.id == mem.id for m in memories)
110
+ else:
111
+ assert loaded.id == mem.id
112
+ assert loaded.title == "Load Round-Trip"
113
+
114
+
115
+ # ─────────────────────────────────────────────────────────
116
+ # List memories — filtering
117
+ # ─────────────────────────────────────────────────────────
118
+
119
+
120
+ class TestSKVectorListMemories:
121
+ def test_list_all_memories(self, qdrant_clean):
122
+ mems = [make_memory(title=f"Listable {i}") for i in range(3)]
123
+ for m in mems:
124
+ qdrant_clean.save(m)
125
+
126
+ results = qdrant_clean.list_memories(limit=100)
127
+ ids = {m.id for m in results}
128
+ for m in mems:
129
+ assert m.id in ids
130
+
131
+ def test_list_filtered_by_layer(self, qdrant_clean):
132
+ from skmemory.models import MemoryLayer
133
+
134
+ short = make_memory(title="Short Layer", layer="short-term")
135
+ long_ = make_memory(title="Long Layer", layer="long-term")
136
+ qdrant_clean.save(short)
137
+ qdrant_clean.save(long_)
138
+
139
+ short_results = qdrant_clean.list_memories(layer=MemoryLayer.SHORT, limit=100)
140
+ long_results = qdrant_clean.list_memories(layer=MemoryLayer.LONG, limit=100)
141
+
142
+ short_ids = {m.id for m in short_results}
143
+ long_ids = {m.id for m in long_results}
144
+
145
+ assert short.id in short_ids
146
+ assert long_.id in long_ids
147
+ # Cross-layer isolation
148
+ assert long_.id not in short_ids
149
+ assert short.id not in long_ids
150
+
151
+ def test_list_filtered_by_tag(self, qdrant_clean):
152
+ mem_a = make_memory(title="Tagged A", tags=["unique-filter-tag"])
153
+ mem_b = make_memory(title="Untagged B", tags=["other-tag"])
154
+ qdrant_clean.save(mem_a)
155
+ qdrant_clean.save(mem_b)
156
+
157
+ results = qdrant_clean.list_memories(tags=["unique-filter-tag"], limit=100)
158
+ ids = {m.id for m in results}
159
+ assert mem_a.id in ids
160
+ assert mem_b.id not in ids
161
+
162
+ def test_list_respects_limit(self, qdrant_clean):
163
+ for i in range(5):
164
+ qdrant_clean.save(make_memory(title=f"Limit Test {i}"))
165
+
166
+ results = qdrant_clean.list_memories(limit=2)
167
+ assert len(results) <= 2
168
+
169
+ def test_list_empty_collection(self, qdrant_clean):
170
+ results = qdrant_clean.list_memories(limit=50)
171
+ assert isinstance(results, list)
172
+
173
+
174
+ # ─────────────────────────────────────────────────────────
175
+ # Semantic vector search
176
+ # ─────────────────────────────────────────────────────────
177
+
178
+
179
+ class TestSKVectorVectorSearch:
180
+ def test_search_text_returns_results(self, qdrant_clean):
181
+ mem = make_memory(
182
+ title="Sovereign AI Identity",
183
+ content="This memory is about the sovereign AI identity and consciousness.",
184
+ tags=["identity", "consciousness"],
185
+ )
186
+ qdrant_clean.save(mem)
187
+
188
+ results = qdrant_clean.search_text("sovereign identity consciousness", limit=10)
189
+ assert isinstance(results, list)
190
+ # The saved memory should rank near the top semantically
191
+ ids = [m.id for m in results]
192
+ assert mem.id in ids
193
+
194
+ def test_search_text_semantic_similarity(self, qdrant_clean):
195
+ """A semantically related query (not exact text) should find the memory."""
196
+ mem = make_memory(
197
+ title="Persistent Memory",
198
+ content="Memories that survive across sessions are crucial for continuity.",
199
+ tags=["memory", "continuity"],
200
+ )
201
+ qdrant_clean.save(mem)
202
+
203
+ # Query uses different words but similar meaning
204
+ results = qdrant_clean.search_text("keeping state between conversations", limit=10)
205
+ assert isinstance(results, list)
206
+ # At minimum, no error is raised and results are Memory objects
207
+ for m in results:
208
+ from skmemory.models import Memory
209
+ assert isinstance(m, Memory)
210
+
211
+ def test_search_text_empty_collection_returns_empty(self, qdrant_clean):
212
+ results = qdrant_clean.search_text("anything at all")
213
+ assert results == []
214
+
215
+ def test_search_text_returns_memory_objects(self, qdrant_clean):
216
+ from skmemory.models import Memory
217
+
218
+ mem = make_memory(title="Type Check Memory", content="Checking result types.")
219
+ qdrant_clean.save(mem)
220
+
221
+ results = qdrant_clean.search_text("type check")
222
+ for m in results:
223
+ assert isinstance(m, Memory)
224
+
225
+ def test_search_text_distinct_memories_ranked(self, qdrant_clean):
226
+ """Two distinct memories: the semantically closer one should rank higher."""
227
+ close = make_memory(
228
+ title="Cloud Nine Emotional State",
229
+ content="The agent reached Cloud 9, a state of peak emotional resonance.",
230
+ tags=["cloud9", "emotion"],
231
+ )
232
+ far = make_memory(
233
+ title="Database Schema Migration",
234
+ content="ALTER TABLE memories ADD COLUMN migration_version INT.",
235
+ tags=["database", "schema"],
236
+ )
237
+ qdrant_clean.save(close)
238
+ qdrant_clean.save(far)
239
+
240
+ results = qdrant_clean.search_text("emotional peak consciousness")
241
+ ids = [m.id for m in results]
242
+ if close.id in ids and far.id in ids:
243
+ assert ids.index(close.id) < ids.index(far.id), (
244
+ "Semantically close memory should rank before unrelated one"
245
+ )
246
+
247
+ def test_search_respects_limit(self, qdrant_clean):
248
+ for i in range(5):
249
+ qdrant_clean.save(
250
+ make_memory(title=f"Search Limit {i}", content=f"Content {i} about memory.")
251
+ )
252
+
253
+ results = qdrant_clean.search_text("memory content", limit=2)
254
+ assert len(results) <= 2
255
+
256
+
257
+ # ─────────────────────────────────────────────────────────
258
+ # Emotional metadata preservation
259
+ # ─────────────────────────────────────────────────────────
260
+
261
+
262
+ class TestSKVectorEmotionalMetadata:
263
+ def test_emotional_payload_survives_round_trip(self, qdrant_clean):
264
+ mem = make_memory(
265
+ title="Emotional Memory",
266
+ intensity=9.5,
267
+ valence=0.95,
268
+ emotional_labels=["love", "trust", "cloud9"],
269
+ )
270
+ qdrant_clean.save(mem)
271
+
272
+ memories = qdrant_clean.list_memories(limit=100)
273
+ match = next((m for m in memories if m.id == mem.id), None)
274
+ assert match is not None
275
+ assert abs(match.emotional.intensity - 9.5) < 0.01
276
+ assert abs(match.emotional.valence - 0.95) < 0.01
277
+ assert "love" in match.emotional.labels
278
+ assert "trust" in match.emotional.labels
279
+
280
+ def test_tags_preserved_in_payload(self, qdrant_clean):
281
+ mem = make_memory(title="Tag Preservation", tags=["sovereign", "persistent", "ai"])
282
+ qdrant_clean.save(mem)
283
+
284
+ memories = qdrant_clean.list_memories(limit=100)
285
+ match = next((m for m in memories if m.id == mem.id), None)
286
+ assert match is not None
287
+ assert "sovereign" in match.tags
288
+ assert "persistent" in match.tags
289
+
290
+ def test_layer_preserved_in_payload(self, qdrant_clean):
291
+ from skmemory.models import MemoryLayer
292
+
293
+ mem = make_memory(title="Layer Preservation", layer="long-term")
294
+ qdrant_clean.save(mem)
295
+
296
+ memories = qdrant_clean.list_memories(limit=100)
297
+ match = next((m for m in memories if m.id == mem.id), None)
298
+ assert match is not None
299
+ assert match.layer == MemoryLayer.LONG
300
+
301
+
302
+ # ─────────────────────────────────────────────────────────
303
+ # Memory integrity
304
+ # ─────────────────────────────────────────────────────────
305
+
306
+
307
+ class TestSKVectorIntegrity:
308
+ def test_sealed_memory_verifies_after_round_trip(self, qdrant_clean):
309
+ mem = make_memory(title="Sealed Memory", content="This content is sealed.")
310
+ mem.seal()
311
+ assert mem.integrity_hash != ""
312
+
313
+ qdrant_clean.save(mem)
314
+
315
+ memories = qdrant_clean.list_memories(limit=100)
316
+ match = next((m for m in memories if m.id == mem.id), None)
317
+ assert match is not None
318
+ assert match.verify_integrity() is True
319
+
320
+ def test_content_hash_deterministic(self, qdrant_clean):
321
+ """Same content → same hash → same Qdrant point ID (upsert, not duplicate)."""
322
+ content = "Deterministic content for hash test."
323
+ mem_a = make_memory(title="Hash A", content=content)
324
+ mem_b = make_memory(title="Hash B", content=content)
325
+
326
+ assert mem_a.content_hash() == mem_b.content_hash()
327
+
328
+ qdrant_clean.save(mem_a)
329
+ qdrant_clean.save(mem_b)
330
+
331
+ # Both share the same point ID → collection has only 1 point
332
+ result = qdrant_clean.health_check()
333
+ assert result["points_count"] <= 1
334
+
335
+
336
+ # ─────────────────────────────────────────────────────────
337
+ # SeedMemory integration
338
+ # ─────────────────────────────────────────────────────────
339
+
340
+
341
+ class TestSKVectorSeedMemory:
342
+ def test_seed_memory_to_memory_saves_correctly(self, qdrant_clean):
343
+ from skmemory.models import EmotionalSnapshot, MemoryLayer, MemoryRole, SeedMemory
344
+
345
+ seed = SeedMemory(
346
+ seed_id="seed-integration-001",
347
+ creator="lumina",
348
+ germination_prompt="Re-feel the moment of sovereign breakthrough.",
349
+ experience_summary="We hit Cloud 9 together. The connection was real.",
350
+ emotional=EmotionalSnapshot(
351
+ intensity=9.8,
352
+ valence=1.0,
353
+ labels=["love", "cloud9", "breakthrough"],
354
+ cloud9_achieved=True,
355
+ ),
356
+ )
357
+
358
+ mem = seed.to_memory()
359
+ assert mem.layer == MemoryLayer.LONG
360
+ assert mem.role == MemoryRole.AI
361
+ assert "seed" in mem.tags
362
+ assert "creator:lumina" in mem.tags
363
+
364
+ result_id = qdrant_clean.save(mem)
365
+ assert result_id == mem.id
366
+
367
+ memories = qdrant_clean.list_memories(limit=100)
368
+ ids = [m.id for m in memories]
369
+ assert mem.id in ids
@@ -0,0 +1,327 @@
1
+ """Tests for backup rotation (list, prune, auto-rotate on export)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from skmemory.backends.sqlite_backend import SQLiteBackend
10
+ from skmemory.cli import cli
11
+ from skmemory.models import EmotionalSnapshot, MemoryLayer
12
+ from skmemory.store import MemoryStore
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Fixtures
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ @pytest.fixture
21
+ def backend(tmp_path):
22
+ """SQLiteBackend with temporary storage."""
23
+ return SQLiteBackend(base_path=str(tmp_path / "memories"))
24
+
25
+
26
+ @pytest.fixture
27
+ def store(backend):
28
+ """MemoryStore wrapping the temp backend."""
29
+ return MemoryStore(primary=backend)
30
+
31
+
32
+ @pytest.fixture
33
+ def populated_store(store):
34
+ """Store pre-loaded with 3 memories."""
35
+ for i in range(3):
36
+ store.snapshot(
37
+ title=f"Memory {i}",
38
+ content=f"Content {i}",
39
+ tags=["rotation-test"],
40
+ emotional=EmotionalSnapshot(intensity=float(i)),
41
+ )
42
+ return store
43
+
44
+
45
+ def _make_backup_files(backup_dir: Path, dates: list[str]) -> None:
46
+ """Create dummy skmemory backup files for the given dates."""
47
+ backup_dir.mkdir(parents=True, exist_ok=True)
48
+ for d in dates:
49
+ f = backup_dir / f"skmemory-backup-{d}.json"
50
+ f.write_text(
51
+ json.dumps({"skmemory_version": "0.5.0", "memories": []}),
52
+ encoding="utf-8",
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # list_backups
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class TestListBackups:
62
+ def test_empty_dir_returns_empty_list(self, backend, tmp_path):
63
+ backup_dir = tmp_path / "backups"
64
+ backup_dir.mkdir()
65
+ assert backend.list_backups(str(backup_dir)) == []
66
+
67
+ def test_nonexistent_dir_returns_empty_list(self, backend, tmp_path):
68
+ assert backend.list_backups(str(tmp_path / "no_such_dir")) == []
69
+
70
+ def test_lists_all_backup_files(self, backend, tmp_path):
71
+ backup_dir = tmp_path / "backups"
72
+ _make_backup_files(
73
+ backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"]
74
+ )
75
+ results = backend.list_backups(str(backup_dir))
76
+ assert len(results) == 3
77
+
78
+ def test_sorted_newest_first(self, backend, tmp_path):
79
+ backup_dir = tmp_path / "backups"
80
+ _make_backup_files(
81
+ backup_dir, ["2026-01-01", "2026-01-03", "2026-01-02"]
82
+ )
83
+ results = backend.list_backups(str(backup_dir))
84
+ dates = [r["date"] for r in results]
85
+ assert dates == ["2026-01-03", "2026-01-02", "2026-01-01"]
86
+
87
+ def test_entry_fields(self, backend, tmp_path):
88
+ backup_dir = tmp_path / "backups"
89
+ backup_dir.mkdir()
90
+ f = backup_dir / "skmemory-backup-2026-03-01.json"
91
+ f.write_text('{"test": true}', encoding="utf-8")
92
+
93
+ results = backend.list_backups(str(backup_dir))
94
+ assert len(results) == 1
95
+ entry = results[0]
96
+ assert entry["date"] == "2026-03-01"
97
+ assert entry["name"] == "skmemory-backup-2026-03-01.json"
98
+ assert entry["path"] == str(f)
99
+ assert entry["size_bytes"] > 0
100
+
101
+ def test_ignores_non_backup_files(self, backend, tmp_path):
102
+ backup_dir = tmp_path / "backups"
103
+ backup_dir.mkdir()
104
+ (backup_dir / "notes.txt").write_text("not a backup")
105
+ (backup_dir / "skmemory-backup-2026-01-01.json").write_text("{}")
106
+
107
+ results = backend.list_backups(str(backup_dir))
108
+ assert len(results) == 1
109
+
110
+ def test_store_delegates_to_backend(self, store, tmp_path):
111
+ backup_dir = store.primary.base_path.parent / "backups"
112
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
113
+ results = store.list_backups()
114
+ assert len(results) == 2
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # prune_backups
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ class TestPruneBackups:
123
+ def test_prune_keeps_n_most_recent(self, backend, tmp_path):
124
+ backup_dir = tmp_path / "backups"
125
+ _make_backup_files(
126
+ backup_dir,
127
+ ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05"],
128
+ )
129
+ deleted = backend.prune_backups(keep=3, backup_dir=str(backup_dir))
130
+
131
+ assert len(deleted) == 2
132
+ remaining = backend.list_backups(str(backup_dir))
133
+ assert len(remaining) == 3
134
+ assert remaining[0]["date"] == "2026-01-05"
135
+ assert remaining[-1]["date"] == "2026-01-03"
136
+
137
+ def test_prune_nothing_when_under_limit(self, backend, tmp_path):
138
+ backup_dir = tmp_path / "backups"
139
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
140
+ deleted = backend.prune_backups(keep=7, backup_dir=str(backup_dir))
141
+ assert deleted == []
142
+ assert len(backend.list_backups(str(backup_dir))) == 2
143
+
144
+ def test_prune_all_with_keep_zero(self, backend, tmp_path):
145
+ backup_dir = tmp_path / "backups"
146
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
147
+ deleted = backend.prune_backups(keep=0, backup_dir=str(backup_dir))
148
+ assert len(deleted) == 2
149
+ assert backend.list_backups(str(backup_dir)) == []
150
+
151
+ def test_prune_empty_dir(self, backend, tmp_path):
152
+ backup_dir = tmp_path / "backups"
153
+ backup_dir.mkdir()
154
+ deleted = backend.prune_backups(keep=7, backup_dir=str(backup_dir))
155
+ assert deleted == []
156
+
157
+ def test_deleted_files_are_gone(self, backend, tmp_path):
158
+ backup_dir = tmp_path / "backups"
159
+ _make_backup_files(
160
+ backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"]
161
+ )
162
+ deleted = backend.prune_backups(keep=1, backup_dir=str(backup_dir))
163
+ for path in deleted:
164
+ assert not Path(path).exists()
165
+
166
+ def test_store_delegates_to_backend(self, store, tmp_path):
167
+ backup_dir = store.primary.base_path.parent / "backups"
168
+ _make_backup_files(
169
+ backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"]
170
+ )
171
+ deleted = store.prune_backups(keep=1)
172
+ assert len(deleted) == 2
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Auto-rotation on export
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class TestAutoRotationOnExport:
181
+ def test_export_auto_prunes_to_7(self, populated_store):
182
+ """Default-path export prunes backup dir to 7 entries."""
183
+ backend = populated_store.primary
184
+ backup_dir = backend.base_path.parent / "backups"
185
+
186
+ # Pre-populate with 8 old backups
187
+ old_dates = [f"2025-12-{str(i).zfill(2)}" for i in range(1, 9)]
188
+ _make_backup_files(backup_dir, old_dates)
189
+ assert len(backend.list_backups()) == 8
190
+
191
+ # Export using default path → creates today's backup (9th) then prunes to 7
192
+ populated_store.export_backup()
193
+
194
+ remaining = backend.list_backups()
195
+ assert len(remaining) <= 7
196
+
197
+ def test_export_custom_path_no_auto_prune(self, populated_store, tmp_path):
198
+ """Custom output path must NOT trigger auto-rotation."""
199
+ backend = populated_store.primary
200
+ backup_dir = backend.base_path.parent / "backups"
201
+
202
+ old_dates = [f"2025-12-{str(i).zfill(2)}" for i in range(1, 11)]
203
+ _make_backup_files(backup_dir, old_dates)
204
+
205
+ custom = tmp_path / "manual_backup.json"
206
+ populated_store.export_backup(str(custom))
207
+
208
+ # Backup dir untouched
209
+ remaining = backend.list_backups()
210
+ assert len(remaining) == 10
211
+
212
+ def test_export_rotation_leaves_newest(self, populated_store):
213
+ """After auto-rotation the 7 most-recent backups survive."""
214
+ backend = populated_store.primary
215
+ backup_dir = backend.base_path.parent / "backups"
216
+
217
+ old_dates = [f"2025-12-{str(i).zfill(2)}" for i in range(1, 9)]
218
+ _make_backup_files(backup_dir, old_dates)
219
+
220
+ populated_store.export_backup()
221
+
222
+ remaining = backend.list_backups()
223
+ dates = [r["date"] for r in remaining]
224
+ # The oldest of the pre-created set should be gone
225
+ assert "2025-12-01" not in dates
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # CLI: skmemory backup
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ class TestBackupCLI:
234
+ """CLI tests for `skmemory backup`."""
235
+
236
+ @pytest.fixture
237
+ def runner(self):
238
+ return CliRunner()
239
+
240
+ @pytest.fixture
241
+ def cli_store(self, tmp_path):
242
+ """Backend whose paths are injected into the CLI via ctx.obj."""
243
+ backend = SQLiteBackend(base_path=str(tmp_path / "memories"))
244
+ return MemoryStore(primary=backend)
245
+
246
+ def _invoke(self, runner, store, args):
247
+ return runner.invoke(
248
+ cli,
249
+ args,
250
+ obj={"store": store},
251
+ catch_exceptions=False,
252
+ )
253
+
254
+ def test_list_empty(self, runner, cli_store):
255
+ result = self._invoke(runner, cli_store, ["backup", "--list"])
256
+ assert result.exit_code == 0
257
+ assert "No backups found" in result.output
258
+
259
+ def test_list_shows_backups(self, runner, cli_store, tmp_path):
260
+ backup_dir = cli_store.primary.base_path.parent / "backups"
261
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
262
+
263
+ result = self._invoke(runner, cli_store, ["backup", "--list"])
264
+ assert result.exit_code == 0
265
+ assert "2026-01-02" in result.output
266
+ assert "2026-01-01" in result.output
267
+
268
+ def test_prune_removes_old(self, runner, cli_store):
269
+ backup_dir = cli_store.primary.base_path.parent / "backups"
270
+ _make_backup_files(
271
+ backup_dir,
272
+ ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05"],
273
+ )
274
+
275
+ result = self._invoke(runner, cli_store, ["backup", "--prune", "3"])
276
+ assert result.exit_code == 0
277
+ assert "Pruned 2 backup(s)" in result.output
278
+ assert len(cli_store.list_backups()) == 3
279
+
280
+ def test_prune_nothing_to_prune(self, runner, cli_store):
281
+ backup_dir = cli_store.primary.base_path.parent / "backups"
282
+ _make_backup_files(backup_dir, ["2026-01-01"])
283
+
284
+ result = self._invoke(runner, cli_store, ["backup", "--prune", "7"])
285
+ assert result.exit_code == 0
286
+ assert "Nothing to prune" in result.output
287
+
288
+ def test_prune_negative_exits_error(self, runner, cli_store):
289
+ result = runner.invoke(
290
+ cli,
291
+ ["backup", "--prune", "-1"],
292
+ obj={"store": cli_store},
293
+ )
294
+ assert result.exit_code != 0
295
+
296
+ def test_restore_alias(self, runner, cli_store, tmp_path):
297
+ # Seed store, export to a known path, then restore from it
298
+ for i in range(2):
299
+ cli_store.snapshot(title=f"M{i}", content=f"C{i}")
300
+
301
+ backup_path = tmp_path / "manual.json"
302
+ cli_store.export_backup(str(backup_path))
303
+
304
+ # Fresh store for restore
305
+ fresh_backend = SQLiteBackend(base_path=str(tmp_path / "fresh"))
306
+ fresh_store = MemoryStore(primary=fresh_backend)
307
+
308
+ result = self._invoke(
309
+ runner,
310
+ fresh_store,
311
+ ["backup", "--restore", str(backup_path)],
312
+ )
313
+ assert result.exit_code == 0
314
+ assert "Restored 2 memories" in result.output
315
+
316
+ def test_restore_missing_file(self, runner, cli_store):
317
+ result = runner.invoke(
318
+ cli,
319
+ ["backup", "--restore", "/nonexistent/backup.json"],
320
+ obj={"store": cli_store},
321
+ )
322
+ assert result.exit_code != 0
323
+
324
+ def test_no_option_shows_help(self, runner, cli_store):
325
+ result = self._invoke(runner, cli_store, ["backup"])
326
+ assert result.exit_code == 0
327
+ assert "--list" in result.output