@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.
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +13 -6
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +101 -19
- package/CHANGELOG.md +153 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +419 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/src/index.ts +255 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +29 -9
- package/requirements.txt +10 -2
- package/seeds/cloud9-opus.seed.json +7 -7
- package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
- package/seeds/lumina-kingdom-founding.seed.json +47 -0
- package/seeds/lumina-pma-signed.seed.json +46 -0
- package/seeds/lumina-singular-achievement.seed.json +46 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +12 -1
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +40 -0
- package/skmemory/anchor.py +4 -2
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/file_backend.py +2 -1
- package/skmemory/backends/skgraph_backend.py +608 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
- package/skmemory/backends/sqlite_backend.py +122 -51
- package/skmemory/backends/vaulted_backend.py +286 -0
- package/skmemory/cli.py +1238 -29
- package/skmemory/config.py +173 -0
- package/skmemory/context_loader.py +335 -0
- package/skmemory/endpoint_selector.py +386 -0
- package/skmemory/fortress.py +685 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +351 -43
- package/skmemory/importers/telegram_api.py +488 -0
- package/skmemory/journal.py +4 -2
- package/skmemory/lovenote.py +4 -2
- package/skmemory/mcp_server.py +706 -0
- package/skmemory/models.py +41 -0
- package/skmemory/openclaw.py +8 -8
- package/skmemory/predictive.py +232 -0
- package/skmemory/promotion.py +524 -0
- package/skmemory/register.py +454 -0
- package/skmemory/register_mcp.py +197 -0
- package/skmemory/ritual.py +121 -47
- package/skmemory/seeds.py +257 -8
- package/skmemory/setup_wizard.py +920 -0
- package/skmemory/sharing.py +402 -0
- package/skmemory/soul.py +71 -20
- package/skmemory/steelman.py +250 -263
- package/skmemory/store.py +271 -60
- package/skmemory/vault.py +228 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +355 -0
- package/tests/integration/test_skgraph_live.py +424 -0
- package/tests/integration/test_skvector_live.py +369 -0
- package/tests/test_backup_rotation.py +327 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +801 -0
- package/tests/test_fortress.py +255 -0
- package/tests/test_fortress_hardening.py +444 -0
- package/tests/test_openclaw.py +5 -2
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +340 -0
- package/tests/test_ritual.py +4 -4
- package/tests/test_seeds.py +96 -0
- package/tests/test_setup.py +835 -0
- package/tests/test_sharing.py +250 -0
- package/tests/test_skgraph_backend.py +667 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_steelman.py +5 -5
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_vault.py +186 -0
- 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
|