@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,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
@@ -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([]) == []