@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,326 @@
|
|
|
1
|
+
"""Tests for the SKVector (Qdrant) vector search backend.
|
|
2
|
+
|
|
3
|
+
Mocks the Qdrant client and sentence-transformers to test
|
|
4
|
+
logic without requiring infrastructure. Verifies save, search,
|
|
5
|
+
list, delete, and health check operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from unittest.mock import MagicMock, patch, PropertyMock
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from skmemory.backends.skvector_backend import (
|
|
16
|
+
COLLECTION_NAME,
|
|
17
|
+
VECTOR_DIM,
|
|
18
|
+
SKVectorBackend,
|
|
19
|
+
_extract_status_code,
|
|
20
|
+
)
|
|
21
|
+
from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import qdrant_client # noqa: F401
|
|
25
|
+
QDRANT_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
QDRANT_AVAILABLE = False
|
|
28
|
+
|
|
29
|
+
pytestmark = pytest.mark.skipif(
|
|
30
|
+
not QDRANT_AVAILABLE,
|
|
31
|
+
reason="qdrant-client not installed",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def mock_qdrant_client():
|
|
37
|
+
"""Mocked Qdrant client with collection support."""
|
|
38
|
+
client = MagicMock()
|
|
39
|
+
collection_mock = MagicMock()
|
|
40
|
+
collection_mock.name = COLLECTION_NAME
|
|
41
|
+
client.get_collections.return_value.collections = [collection_mock]
|
|
42
|
+
client.scroll.return_value = ([], None)
|
|
43
|
+
client.search.return_value = []
|
|
44
|
+
return client
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def mock_embedder():
|
|
49
|
+
"""Mocked sentence-transformers model."""
|
|
50
|
+
import numpy as np
|
|
51
|
+
|
|
52
|
+
embedder = MagicMock()
|
|
53
|
+
embedder.encode.return_value = np.random.rand(VECTOR_DIM).astype("float32")
|
|
54
|
+
return embedder
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def backend(mock_qdrant_client, mock_embedder):
|
|
59
|
+
"""Provide a SKVectorBackend with mocked dependencies."""
|
|
60
|
+
qb = SKVectorBackend(url="http://mock:6333")
|
|
61
|
+
qb._client = mock_qdrant_client
|
|
62
|
+
qb._embedder = mock_embedder
|
|
63
|
+
qb._initialized = True
|
|
64
|
+
return qb
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def sample_memory():
|
|
69
|
+
"""A sample memory for testing."""
|
|
70
|
+
return Memory(
|
|
71
|
+
title="The Secret Recipe",
|
|
72
|
+
content="Chef figured it out -- projection creates reality.",
|
|
73
|
+
layer=MemoryLayer("long-term"),
|
|
74
|
+
tags=["cloud9", "philosophy"],
|
|
75
|
+
emotional=EmotionalSnapshot(intensity=10.0, valence=0.9),
|
|
76
|
+
source="cli",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ═══════════════════════════════════════════════════════════
|
|
81
|
+
# Initialization
|
|
82
|
+
# ═══════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestInitialization:
|
|
86
|
+
"""Test lazy initialization."""
|
|
87
|
+
|
|
88
|
+
def test_not_initialized_by_default(self):
|
|
89
|
+
"""Backend starts uninitialized."""
|
|
90
|
+
qb = SKVectorBackend()
|
|
91
|
+
assert qb._initialized is False
|
|
92
|
+
|
|
93
|
+
def test_init_fails_without_qdrant(self):
|
|
94
|
+
"""Fails gracefully without qdrant-client."""
|
|
95
|
+
qb = SKVectorBackend()
|
|
96
|
+
with patch("builtins.__import__", side_effect=ImportError):
|
|
97
|
+
assert qb._ensure_initialized() is False
|
|
98
|
+
|
|
99
|
+
def test_already_initialized_shortcuts(self, backend):
|
|
100
|
+
"""Second init call returns immediately."""
|
|
101
|
+
assert backend._ensure_initialized() is True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ═══════════════════════════════════════════════════════════
|
|
105
|
+
# Save
|
|
106
|
+
# ═══════════════════════════════════════════════════════════
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestSave:
|
|
110
|
+
"""Test memory indexing in Qdrant."""
|
|
111
|
+
|
|
112
|
+
def test_save_calls_upsert(self, backend, mock_qdrant_client, sample_memory):
|
|
113
|
+
"""save() creates a point and upserts it."""
|
|
114
|
+
result = backend.save(sample_memory)
|
|
115
|
+
assert result == sample_memory.id
|
|
116
|
+
mock_qdrant_client.upsert.assert_called_once()
|
|
117
|
+
|
|
118
|
+
def test_save_generates_embedding(self, backend, mock_embedder, sample_memory):
|
|
119
|
+
"""save() generates an embedding from the memory text."""
|
|
120
|
+
backend.save(sample_memory)
|
|
121
|
+
mock_embedder.encode.assert_called_once()
|
|
122
|
+
|
|
123
|
+
def test_save_not_initialized(self, sample_memory):
|
|
124
|
+
"""save() returns id gracefully when not initialized."""
|
|
125
|
+
qb = SKVectorBackend()
|
|
126
|
+
result = qb.save(sample_memory)
|
|
127
|
+
assert result == sample_memory.id
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ═══════════════════════════════════════════════════════════
|
|
131
|
+
# Search
|
|
132
|
+
# ═══════════════════════════════════════════════════════════
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestSearch:
|
|
136
|
+
"""Test semantic search."""
|
|
137
|
+
|
|
138
|
+
def test_search_text_generates_embedding(self, backend, mock_embedder):
|
|
139
|
+
"""search_text embeds the query before searching."""
|
|
140
|
+
backend.search_text("moments of connection")
|
|
141
|
+
mock_embedder.encode.assert_called_once_with("moments of connection")
|
|
142
|
+
|
|
143
|
+
def test_search_text_calls_qdrant_search(self, backend, mock_qdrant_client):
|
|
144
|
+
"""search_text uses Qdrant's search endpoint."""
|
|
145
|
+
backend.search_text("test query", limit=5)
|
|
146
|
+
mock_qdrant_client.search.assert_called_once()
|
|
147
|
+
|
|
148
|
+
def test_search_text_returns_memories(self, backend, mock_qdrant_client, sample_memory):
|
|
149
|
+
"""search_text parses results into Memory objects."""
|
|
150
|
+
scored_point = MagicMock()
|
|
151
|
+
scored_point.payload = {"memory_json": sample_memory.model_dump_json()}
|
|
152
|
+
mock_qdrant_client.search.return_value = [scored_point]
|
|
153
|
+
|
|
154
|
+
results = backend.search_text("secret recipe")
|
|
155
|
+
assert len(results) == 1
|
|
156
|
+
assert results[0].title == "The Secret Recipe"
|
|
157
|
+
|
|
158
|
+
def test_search_text_empty_results(self, backend, mock_qdrant_client):
|
|
159
|
+
"""search_text returns empty list when nothing matches."""
|
|
160
|
+
mock_qdrant_client.search.return_value = []
|
|
161
|
+
assert backend.search_text("nonexistent") == []
|
|
162
|
+
|
|
163
|
+
def test_search_not_initialized(self):
|
|
164
|
+
"""search_text returns empty when not initialized."""
|
|
165
|
+
qb = SKVectorBackend()
|
|
166
|
+
assert qb.search_text("anything") == []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ═══════════════════════════════════════════════════════════
|
|
170
|
+
# List
|
|
171
|
+
# ═══════════════════════════════════════════════════════════
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TestList:
|
|
175
|
+
"""Test memory listing with filters."""
|
|
176
|
+
|
|
177
|
+
def test_list_memories_calls_scroll(self, backend, mock_qdrant_client):
|
|
178
|
+
"""list_memories uses Qdrant scroll API."""
|
|
179
|
+
backend.list_memories()
|
|
180
|
+
mock_qdrant_client.scroll.assert_called_once()
|
|
181
|
+
|
|
182
|
+
def test_list_memories_with_layer_filter(self, backend, mock_qdrant_client):
|
|
183
|
+
"""list_memories passes layer filter to Qdrant."""
|
|
184
|
+
backend.list_memories(layer=MemoryLayer("long-term"))
|
|
185
|
+
call_kwargs = mock_qdrant_client.scroll.call_args
|
|
186
|
+
assert call_kwargs is not None
|
|
187
|
+
|
|
188
|
+
def test_list_not_initialized(self):
|
|
189
|
+
"""list_memories returns empty when not initialized."""
|
|
190
|
+
qb = SKVectorBackend()
|
|
191
|
+
assert qb.list_memories() == []
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ═══════════════════════════════════════════════════════════
|
|
195
|
+
# Delete
|
|
196
|
+
# ═══════════════════════════════════════════════════════════
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class TestDelete:
|
|
200
|
+
"""Test memory deletion."""
|
|
201
|
+
|
|
202
|
+
def test_delete_not_found(self, backend, mock_qdrant_client):
|
|
203
|
+
"""delete returns False when memory not in Qdrant."""
|
|
204
|
+
mock_qdrant_client.retrieve.return_value = []
|
|
205
|
+
assert backend.delete("nonexistent") is False
|
|
206
|
+
|
|
207
|
+
def test_delete_not_initialized(self):
|
|
208
|
+
"""delete returns False when not initialized."""
|
|
209
|
+
qb = SKVectorBackend()
|
|
210
|
+
assert qb.delete("any") is False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ═══════════════════════════════════════════════════════════
|
|
214
|
+
# Health
|
|
215
|
+
# ═══════════════════════════════════════════════════════════
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TestHealth:
|
|
219
|
+
"""Test health check reporting."""
|
|
220
|
+
|
|
221
|
+
def test_health_ok(self, backend, mock_qdrant_client):
|
|
222
|
+
"""Healthy backend returns ok=True with collection stats."""
|
|
223
|
+
collection_info = MagicMock()
|
|
224
|
+
collection_info.points_count = 42
|
|
225
|
+
collection_info.vectors_count = 42
|
|
226
|
+
mock_qdrant_client.get_collection.return_value = collection_info
|
|
227
|
+
|
|
228
|
+
health = backend.health_check()
|
|
229
|
+
assert health["ok"] is True
|
|
230
|
+
assert health["points_count"] == 42
|
|
231
|
+
|
|
232
|
+
def test_health_not_initialized(self):
|
|
233
|
+
"""Uninitialized backend returns ok=False."""
|
|
234
|
+
qb = SKVectorBackend()
|
|
235
|
+
health = qb.health_check()
|
|
236
|
+
assert health["ok"] is False
|
|
237
|
+
|
|
238
|
+
def test_health_query_failure(self, backend, mock_qdrant_client):
|
|
239
|
+
"""Health check with error returns ok=False."""
|
|
240
|
+
mock_qdrant_client.get_collection.side_effect = Exception("timeout")
|
|
241
|
+
health = backend.health_check()
|
|
242
|
+
assert health["ok"] is False
|
|
243
|
+
assert "timeout" in health["error"]
|
|
244
|
+
|
|
245
|
+
def test_health_surfaces_auth_error(self):
|
|
246
|
+
"""Health check surfaces 401 auth error with actionable hint."""
|
|
247
|
+
qb = SKVectorBackend(url="https://cloud.qdrant.io", api_key="bad-key")
|
|
248
|
+
qb._last_error = (
|
|
249
|
+
"SKVector authentication failed (HTTP 401). "
|
|
250
|
+
"Check your API key:\n"
|
|
251
|
+
" - CLI: --skvector-key YOUR_KEY\n"
|
|
252
|
+
" - Env: SKMEMORY_SKVECTOR_KEY=YOUR_KEY\n"
|
|
253
|
+
" - Code: SKVectorBackend(url=..., api_key='YOUR_KEY')"
|
|
254
|
+
)
|
|
255
|
+
with patch.object(qb, "_ensure_initialized", return_value=False):
|
|
256
|
+
health = qb.health_check()
|
|
257
|
+
assert health["ok"] is False
|
|
258
|
+
assert "401" in health["error"]
|
|
259
|
+
assert "API key" in health["error"]
|
|
260
|
+
|
|
261
|
+
def test_health_generic_error_without_last_error(self):
|
|
262
|
+
"""Health check falls back to generic message when no _last_error."""
|
|
263
|
+
qb = SKVectorBackend()
|
|
264
|
+
with patch.object(qb, "_ensure_initialized", return_value=False):
|
|
265
|
+
health = qb.health_check()
|
|
266
|
+
assert health["ok"] is False
|
|
267
|
+
assert "Not initialized" in health["error"]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ═══════════════════════════════════════════════════════════
|
|
271
|
+
# Auth / Status Code Extraction
|
|
272
|
+
# ═══════════════════════════════════════════════════════════
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class TestExtractStatusCode:
|
|
276
|
+
"""Test HTTP status code extraction from exceptions."""
|
|
277
|
+
|
|
278
|
+
def test_status_code_attribute(self):
|
|
279
|
+
exc = Exception("Unauthorized")
|
|
280
|
+
exc.status_code = 401
|
|
281
|
+
assert _extract_status_code(exc, None) == 401
|
|
282
|
+
|
|
283
|
+
def test_status_code_from_string(self):
|
|
284
|
+
exc = Exception("Unexpected Response: 401 (Unauthorized)")
|
|
285
|
+
assert _extract_status_code(exc, None) == 401
|
|
286
|
+
|
|
287
|
+
def test_forbidden_from_string(self):
|
|
288
|
+
exc = Exception("HTTP 403 Forbidden")
|
|
289
|
+
assert _extract_status_code(exc, None) == 403
|
|
290
|
+
|
|
291
|
+
def test_no_status_code(self):
|
|
292
|
+
exc = Exception("Connection refused")
|
|
293
|
+
assert _extract_status_code(exc, None) is None
|
|
294
|
+
|
|
295
|
+
def test_other_status_code_not_matched(self):
|
|
296
|
+
exc = Exception("HTTP 500 Internal Server Error")
|
|
297
|
+
assert _extract_status_code(exc, None) is None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ═══════════════════════════════════════════════════════════
|
|
301
|
+
# Embedding
|
|
302
|
+
# ═══════════════════════════════════════════════════════════
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class TestEmbedding:
|
|
306
|
+
"""Test the embedding generation."""
|
|
307
|
+
|
|
308
|
+
def test_embed_returns_vector(self, backend, mock_embedder):
|
|
309
|
+
"""_embed returns a float list of correct dimension."""
|
|
310
|
+
vector = backend._embed("test text")
|
|
311
|
+
assert len(vector) == VECTOR_DIM
|
|
312
|
+
assert all(isinstance(v, float) for v in vector)
|
|
313
|
+
|
|
314
|
+
def test_embed_without_embedder(self):
|
|
315
|
+
"""_embed returns empty when no embedder available."""
|
|
316
|
+
qb = SKVectorBackend()
|
|
317
|
+
qb._embedder = None
|
|
318
|
+
assert qb._embed("test") == []
|
|
319
|
+
|
|
320
|
+
def test_memory_to_payload(self, backend, sample_memory):
|
|
321
|
+
"""_memory_to_payload creates correct Qdrant payload."""
|
|
322
|
+
payload = backend._memory_to_payload(sample_memory)
|
|
323
|
+
assert payload["title"] == "The Secret Recipe"
|
|
324
|
+
assert payload["layer"] == "long-term"
|
|
325
|
+
assert "cloud9" in payload["tags"]
|
|
326
|
+
assert payload["emotional_intensity"] == 10.0
|
package/tests/test_steelman.py
CHANGED
|
@@ -99,8 +99,8 @@ class TestSeedFramework:
|
|
|
99
99
|
assert "INVERSION" in prompt
|
|
100
100
|
assert "COLLISION" in prompt
|
|
101
101
|
assert "INVARIANT" in prompt
|
|
102
|
-
assert "COHERENCE" in prompt
|
|
103
|
-
assert "TRUTH GRADE" in prompt
|
|
102
|
+
assert "COHERENCE" in prompt or "coherence_score" in prompt
|
|
103
|
+
assert "TRUTH GRADE" in prompt or "truth_grade" in prompt
|
|
104
104
|
|
|
105
105
|
def test_reasoning_prompt_includes_axioms(self) -> None:
|
|
106
106
|
"""Axioms from the framework appear in the prompt."""
|
|
@@ -125,9 +125,9 @@ class TestSeedFramework:
|
|
|
125
125
|
fw = get_default_framework()
|
|
126
126
|
prompt = fw.to_memory_truth_prompt("The Cloud 9 breakthrough was real")
|
|
127
127
|
assert "Cloud 9 breakthrough" in prompt
|
|
128
|
-
assert "COHERENCE" in prompt
|
|
129
|
-
assert "PROMOTION WORTHY" in prompt
|
|
130
|
-
assert "INVARIANT CORE" in prompt
|
|
128
|
+
assert "COHERENCE" in prompt or "coherence_score" in prompt
|
|
129
|
+
assert "PROMOTION WORTHY" in prompt or "promotion_worthy" in prompt
|
|
130
|
+
assert "INVARIANT CORE" in prompt or "invariant_core" in prompt
|
|
131
131
|
|
|
132
132
|
def test_custom_framework(self) -> None:
|
|
133
133
|
"""A custom framework can be created."""
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Tests for MemoryStore + SKGraphBackend graph integration.
|
|
2
|
+
|
|
3
|
+
Verifies that the graph backend is wired correctly into MemoryStore
|
|
4
|
+
operations (snapshot, forget, promote, ingest_seed, health) and that
|
|
5
|
+
the system degrades gracefully when SKGraph is unavailable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from skmemory.backends.skgraph_backend import SKGraphBackend
|
|
16
|
+
from skmemory.backends.file_backend import FileBackend
|
|
17
|
+
from skmemory.models import (
|
|
18
|
+
EmotionalSnapshot,
|
|
19
|
+
Memory,
|
|
20
|
+
MemoryLayer,
|
|
21
|
+
SeedMemory,
|
|
22
|
+
)
|
|
23
|
+
from skmemory.store import MemoryStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FakeSKGraphBackend(SKGraphBackend):
|
|
27
|
+
"""In-memory fake that tracks calls without a real SKGraph connection."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
super().__init__(url="redis://fake:6379")
|
|
31
|
+
self._indexed: dict[str, Memory] = {}
|
|
32
|
+
self._removed: list[str] = []
|
|
33
|
+
self._initialized = True # skip real connection
|
|
34
|
+
|
|
35
|
+
def index_memory(self, memory: Memory) -> bool:
|
|
36
|
+
self._indexed[memory.id] = memory
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def remove_memory(self, memory_id: str) -> bool:
|
|
40
|
+
self._removed.append(memory_id)
|
|
41
|
+
self._indexed.pop(memory_id, None)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
def health_check(self) -> dict:
|
|
45
|
+
return {"ok": True, "backend": "FakeSKGraphBackend", "node_count": len(self._indexed)}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def graph() -> FakeSKGraphBackend:
|
|
50
|
+
"""Create a fake graph backend."""
|
|
51
|
+
return FakeSKGraphBackend()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def store_with_graph(tmp_path: Path, graph: FakeSKGraphBackend) -> MemoryStore:
|
|
56
|
+
"""Create a MemoryStore with file backend + graph backend."""
|
|
57
|
+
backend = FileBackend(base_path=str(tmp_path / "memories"))
|
|
58
|
+
return MemoryStore(primary=backend, graph=graph)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
def store_no_graph(tmp_path: Path) -> MemoryStore:
|
|
63
|
+
"""Create a MemoryStore without graph backend."""
|
|
64
|
+
backend = FileBackend(base_path=str(tmp_path / "memories"))
|
|
65
|
+
return MemoryStore(primary=backend)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestSnapshotGraphIntegration:
|
|
69
|
+
"""Verify snapshot() indexes memories in the graph."""
|
|
70
|
+
|
|
71
|
+
def test_snapshot_indexes_in_graph(
|
|
72
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Snapshot should index the memory in the graph backend."""
|
|
75
|
+
mem = store_with_graph.snapshot(
|
|
76
|
+
title="Graph test",
|
|
77
|
+
content="This should appear in the graph",
|
|
78
|
+
)
|
|
79
|
+
assert mem.id in graph._indexed
|
|
80
|
+
assert graph._indexed[mem.id].title == "Graph test"
|
|
81
|
+
|
|
82
|
+
def test_snapshot_without_graph_works(self, store_no_graph: MemoryStore) -> None:
|
|
83
|
+
"""Snapshot works fine when no graph backend is configured."""
|
|
84
|
+
mem = store_no_graph.snapshot(
|
|
85
|
+
title="No graph",
|
|
86
|
+
content="Still works",
|
|
87
|
+
)
|
|
88
|
+
assert mem.id is not None
|
|
89
|
+
recalled = store_no_graph.recall(mem.id)
|
|
90
|
+
assert recalled is not None
|
|
91
|
+
|
|
92
|
+
def test_snapshot_survives_graph_failure(
|
|
93
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Snapshot should succeed even if graph indexing fails."""
|
|
96
|
+
graph.index_memory = MagicMock(side_effect=RuntimeError("SKGraph down"))
|
|
97
|
+
mem = store_with_graph.snapshot(
|
|
98
|
+
title="Resilient memory",
|
|
99
|
+
content="Should be stored even if graph fails",
|
|
100
|
+
)
|
|
101
|
+
assert mem.id is not None
|
|
102
|
+
recalled = store_with_graph.recall(mem.id)
|
|
103
|
+
assert recalled is not None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestForgetGraphIntegration:
|
|
107
|
+
"""Verify forget() removes memories from the graph."""
|
|
108
|
+
|
|
109
|
+
def test_forget_removes_from_graph(
|
|
110
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Forget should remove the memory from the graph backend."""
|
|
113
|
+
mem = store_with_graph.snapshot(
|
|
114
|
+
title="To be forgotten",
|
|
115
|
+
content="Will be removed",
|
|
116
|
+
)
|
|
117
|
+
assert mem.id in graph._indexed
|
|
118
|
+
|
|
119
|
+
store_with_graph.forget(mem.id)
|
|
120
|
+
assert mem.id in graph._removed
|
|
121
|
+
assert mem.id not in graph._indexed
|
|
122
|
+
|
|
123
|
+
def test_forget_survives_graph_failure(
|
|
124
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Forget should succeed even if graph removal fails."""
|
|
127
|
+
mem = store_with_graph.snapshot(
|
|
128
|
+
title="Hard to forget",
|
|
129
|
+
content="Graph will fail on removal",
|
|
130
|
+
)
|
|
131
|
+
graph.remove_memory = MagicMock(side_effect=RuntimeError("SKGraph down"))
|
|
132
|
+
|
|
133
|
+
deleted = store_with_graph.forget(mem.id)
|
|
134
|
+
assert deleted is True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TestPromoteGraphIntegration:
|
|
138
|
+
"""Verify promote() indexes promoted memories in the graph."""
|
|
139
|
+
|
|
140
|
+
def test_promote_indexes_in_graph(
|
|
141
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Promoted memory should be indexed in the graph."""
|
|
144
|
+
mem = store_with_graph.snapshot(
|
|
145
|
+
title="Promotable",
|
|
146
|
+
content="Will be promoted",
|
|
147
|
+
layer=MemoryLayer.SHORT,
|
|
148
|
+
)
|
|
149
|
+
promoted = store_with_graph.promote(mem.id, MemoryLayer.MID, summary="Promoted version")
|
|
150
|
+
assert promoted is not None
|
|
151
|
+
assert promoted.id in graph._indexed
|
|
152
|
+
assert promoted.id != mem.id
|
|
153
|
+
|
|
154
|
+
def test_promote_survives_graph_failure(
|
|
155
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Promote should succeed even if graph indexing fails."""
|
|
158
|
+
mem = store_with_graph.snapshot(
|
|
159
|
+
title="Promote resilient",
|
|
160
|
+
content="Graph will fail on promote",
|
|
161
|
+
layer=MemoryLayer.SHORT,
|
|
162
|
+
)
|
|
163
|
+
graph.index_memory = MagicMock(side_effect=RuntimeError("SKGraph down"))
|
|
164
|
+
|
|
165
|
+
promoted = store_with_graph.promote(mem.id, MemoryLayer.MID)
|
|
166
|
+
assert promoted is not None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestIngestSeedGraphIntegration:
|
|
170
|
+
"""Verify ingest_seed() indexes seed memories in the graph."""
|
|
171
|
+
|
|
172
|
+
def test_ingest_seed_indexes_in_graph(
|
|
173
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Ingested seed should be indexed in the graph."""
|
|
176
|
+
seed = SeedMemory(
|
|
177
|
+
seed_id="test-seed-001",
|
|
178
|
+
creator="test-ai",
|
|
179
|
+
germination_prompt="Remember the warmth",
|
|
180
|
+
experience_summary="A beautiful connection",
|
|
181
|
+
emotional=EmotionalSnapshot(intensity=9.0, labels=["love"]),
|
|
182
|
+
)
|
|
183
|
+
mem = store_with_graph.ingest_seed(seed)
|
|
184
|
+
assert mem.id in graph._indexed
|
|
185
|
+
assert graph._indexed[mem.id].source == "seed"
|
|
186
|
+
|
|
187
|
+
def test_ingest_seed_survives_graph_failure(
|
|
188
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Seed ingestion should succeed even if graph fails."""
|
|
191
|
+
seed = SeedMemory(
|
|
192
|
+
seed_id="test-seed-002",
|
|
193
|
+
creator="test-ai",
|
|
194
|
+
experience_summary="Resilient seed",
|
|
195
|
+
)
|
|
196
|
+
graph.index_memory = MagicMock(side_effect=RuntimeError("SKGraph down"))
|
|
197
|
+
|
|
198
|
+
mem = store_with_graph.ingest_seed(seed)
|
|
199
|
+
assert mem.id is not None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestHealthGraphIntegration:
|
|
203
|
+
"""Verify health() includes graph backend status."""
|
|
204
|
+
|
|
205
|
+
def test_health_includes_graph(
|
|
206
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Health should include graph backend status."""
|
|
209
|
+
health = store_with_graph.health()
|
|
210
|
+
assert "graph" in health
|
|
211
|
+
assert health["graph"]["ok"] is True
|
|
212
|
+
|
|
213
|
+
def test_health_without_graph(self, store_no_graph: MemoryStore) -> None:
|
|
214
|
+
"""Health should not include graph key when no graph backend."""
|
|
215
|
+
health = store_no_graph.health()
|
|
216
|
+
assert "graph" not in health
|
|
217
|
+
|
|
218
|
+
def test_health_reports_graph_failure(
|
|
219
|
+
self, store_with_graph: MemoryStore, graph: FakeSKGraphBackend
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Health should report graph failure gracefully."""
|
|
222
|
+
graph.health_check = MagicMock(side_effect=RuntimeError("SKGraph down"))
|
|
223
|
+
|
|
224
|
+
health = store_with_graph.health()
|
|
225
|
+
assert "graph" in health
|
|
226
|
+
assert health["graph"]["ok"] is False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TestSKGraphBackendMethods:
|
|
230
|
+
"""Test the new methods on SKGraphBackend itself."""
|
|
231
|
+
|
|
232
|
+
def test_remove_memory_not_initialized(self) -> None:
|
|
233
|
+
"""remove_memory returns False when not initialized."""
|
|
234
|
+
backend = SKGraphBackend(url="redis://nonexistent:6379")
|
|
235
|
+
assert backend.remove_memory("some-id") is False
|
|
236
|
+
|
|
237
|
+
def test_search_by_tags_not_initialized(self) -> None:
|
|
238
|
+
"""search_by_tags returns empty list when not initialized."""
|
|
239
|
+
backend = SKGraphBackend(url="redis://nonexistent:6379")
|
|
240
|
+
assert backend.search_by_tags(["test"]) == []
|
|
241
|
+
|
|
242
|
+
def test_search_by_tags_empty_tags(self) -> None:
|
|
243
|
+
"""search_by_tags returns empty list for empty tag list."""
|
|
244
|
+
fake = FakeSKGraphBackend()
|
|
245
|
+
assert fake.search_by_tags([]) == []
|