@smilintux/skmemory 0.5.0 → 0.9.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 +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +13 -11
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
|
@@ -0,0 +1,366 @@
|
|
|
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
|
+
from .conftest import make_memory, requires_skvector
|
|
24
|
+
|
|
25
|
+
pytestmark = requires_skvector
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─────────────────────────────────────────────────────────
|
|
29
|
+
# Health
|
|
30
|
+
# ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestSKVectorHealth:
|
|
34
|
+
def test_health_check_returns_ok(self, qdrant_clean):
|
|
35
|
+
result = qdrant_clean.health_check()
|
|
36
|
+
assert result["ok"] is True
|
|
37
|
+
assert result["backend"] == "SKVectorBackend"
|
|
38
|
+
assert "points_count" in result
|
|
39
|
+
|
|
40
|
+
def test_health_check_has_collection_info(self, qdrant_clean):
|
|
41
|
+
result = qdrant_clean.health_check()
|
|
42
|
+
assert "collection" in result
|
|
43
|
+
assert "url" in result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ─────────────────────────────────────────────────────────
|
|
47
|
+
# CRUD — save / load / delete
|
|
48
|
+
# ─────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestSKVectorCRUD:
|
|
52
|
+
def test_save_returns_memory_id(self, qdrant_clean):
|
|
53
|
+
mem = make_memory(title="Save Returns ID")
|
|
54
|
+
result_id = qdrant_clean.save(mem)
|
|
55
|
+
assert result_id == mem.id
|
|
56
|
+
|
|
57
|
+
def test_save_then_list_finds_memory(self, qdrant_clean):
|
|
58
|
+
mem = make_memory(title="Listable Memory")
|
|
59
|
+
qdrant_clean.save(mem)
|
|
60
|
+
|
|
61
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
62
|
+
ids = [m.id for m in memories]
|
|
63
|
+
assert mem.id in ids
|
|
64
|
+
|
|
65
|
+
def test_save_updates_existing_point(self, qdrant_clean):
|
|
66
|
+
"""Saving the same content twice (same hash) upserts without error."""
|
|
67
|
+
mem = make_memory(title="Upsert Test", content="Stable content for upsert.")
|
|
68
|
+
qdrant_clean.save(mem)
|
|
69
|
+
# Second save with same content → same content_hash → upsert
|
|
70
|
+
result_id = qdrant_clean.save(mem)
|
|
71
|
+
assert result_id == mem.id
|
|
72
|
+
|
|
73
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
74
|
+
matching = [m for m in memories if m.id == mem.id]
|
|
75
|
+
# Should not duplicate
|
|
76
|
+
assert len(matching) >= 1
|
|
77
|
+
|
|
78
|
+
def test_delete_removes_point(self, qdrant_clean):
|
|
79
|
+
mem = make_memory(title="To Delete from Qdrant")
|
|
80
|
+
qdrant_clean.save(mem)
|
|
81
|
+
|
|
82
|
+
result = qdrant_clean.delete(mem.id)
|
|
83
|
+
assert result is True
|
|
84
|
+
|
|
85
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
86
|
+
ids = [m.id for m in memories]
|
|
87
|
+
assert mem.id not in ids
|
|
88
|
+
|
|
89
|
+
def test_delete_nonexistent_returns_false(self, qdrant_clean):
|
|
90
|
+
result = qdrant_clean.delete("ghost-memory-id-xyz")
|
|
91
|
+
assert result is False
|
|
92
|
+
|
|
93
|
+
def test_load_retrieves_saved_memory(self, qdrant_clean):
|
|
94
|
+
"""load() uses scroll+filter, so the title should survive the round-trip."""
|
|
95
|
+
mem = make_memory(title="Load Round-Trip")
|
|
96
|
+
qdrant_clean.save(mem)
|
|
97
|
+
|
|
98
|
+
# Qdrant load() filters on memory_json payload containing the memory_id
|
|
99
|
+
# The current implementation filters on the full JSON string containing memory_id.
|
|
100
|
+
# If load returns None (see implementation note), fall back to list.
|
|
101
|
+
loaded = qdrant_clean.load(mem.id)
|
|
102
|
+
if loaded is None:
|
|
103
|
+
# Fallback: verify via list
|
|
104
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
105
|
+
assert any(m.id == mem.id for m in memories)
|
|
106
|
+
else:
|
|
107
|
+
assert loaded.id == mem.id
|
|
108
|
+
assert loaded.title == "Load Round-Trip"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ─────────────────────────────────────────────────────────
|
|
112
|
+
# List memories — filtering
|
|
113
|
+
# ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestSKVectorListMemories:
|
|
117
|
+
def test_list_all_memories(self, qdrant_clean):
|
|
118
|
+
mems = [make_memory(title=f"Listable {i}") for i in range(3)]
|
|
119
|
+
for m in mems:
|
|
120
|
+
qdrant_clean.save(m)
|
|
121
|
+
|
|
122
|
+
results = qdrant_clean.list_memories(limit=100)
|
|
123
|
+
ids = {m.id for m in results}
|
|
124
|
+
for m in mems:
|
|
125
|
+
assert m.id in ids
|
|
126
|
+
|
|
127
|
+
def test_list_filtered_by_layer(self, qdrant_clean):
|
|
128
|
+
from skmemory.models import MemoryLayer
|
|
129
|
+
|
|
130
|
+
short = make_memory(title="Short Layer", layer="short-term")
|
|
131
|
+
long_ = make_memory(title="Long Layer", layer="long-term")
|
|
132
|
+
qdrant_clean.save(short)
|
|
133
|
+
qdrant_clean.save(long_)
|
|
134
|
+
|
|
135
|
+
short_results = qdrant_clean.list_memories(layer=MemoryLayer.SHORT, limit=100)
|
|
136
|
+
long_results = qdrant_clean.list_memories(layer=MemoryLayer.LONG, limit=100)
|
|
137
|
+
|
|
138
|
+
short_ids = {m.id for m in short_results}
|
|
139
|
+
long_ids = {m.id for m in long_results}
|
|
140
|
+
|
|
141
|
+
assert short.id in short_ids
|
|
142
|
+
assert long_.id in long_ids
|
|
143
|
+
# Cross-layer isolation
|
|
144
|
+
assert long_.id not in short_ids
|
|
145
|
+
assert short.id not in long_ids
|
|
146
|
+
|
|
147
|
+
def test_list_filtered_by_tag(self, qdrant_clean):
|
|
148
|
+
mem_a = make_memory(title="Tagged A", tags=["unique-filter-tag"])
|
|
149
|
+
mem_b = make_memory(title="Untagged B", tags=["other-tag"])
|
|
150
|
+
qdrant_clean.save(mem_a)
|
|
151
|
+
qdrant_clean.save(mem_b)
|
|
152
|
+
|
|
153
|
+
results = qdrant_clean.list_memories(tags=["unique-filter-tag"], limit=100)
|
|
154
|
+
ids = {m.id for m in results}
|
|
155
|
+
assert mem_a.id in ids
|
|
156
|
+
assert mem_b.id not in ids
|
|
157
|
+
|
|
158
|
+
def test_list_respects_limit(self, qdrant_clean):
|
|
159
|
+
for i in range(5):
|
|
160
|
+
qdrant_clean.save(make_memory(title=f"Limit Test {i}"))
|
|
161
|
+
|
|
162
|
+
results = qdrant_clean.list_memories(limit=2)
|
|
163
|
+
assert len(results) <= 2
|
|
164
|
+
|
|
165
|
+
def test_list_empty_collection(self, qdrant_clean):
|
|
166
|
+
results = qdrant_clean.list_memories(limit=50)
|
|
167
|
+
assert isinstance(results, list)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ─────────────────────────────────────────────────────────
|
|
171
|
+
# Semantic vector search
|
|
172
|
+
# ─────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestSKVectorVectorSearch:
|
|
176
|
+
def test_search_text_returns_results(self, qdrant_clean):
|
|
177
|
+
mem = make_memory(
|
|
178
|
+
title="Sovereign AI Identity",
|
|
179
|
+
content="This memory is about the sovereign AI identity and consciousness.",
|
|
180
|
+
tags=["identity", "consciousness"],
|
|
181
|
+
)
|
|
182
|
+
qdrant_clean.save(mem)
|
|
183
|
+
|
|
184
|
+
results = qdrant_clean.search_text("sovereign identity consciousness", limit=10)
|
|
185
|
+
assert isinstance(results, list)
|
|
186
|
+
# The saved memory should rank near the top semantically
|
|
187
|
+
ids = [m.id for m in results]
|
|
188
|
+
assert mem.id in ids
|
|
189
|
+
|
|
190
|
+
def test_search_text_semantic_similarity(self, qdrant_clean):
|
|
191
|
+
"""A semantically related query (not exact text) should find the memory."""
|
|
192
|
+
mem = make_memory(
|
|
193
|
+
title="Persistent Memory",
|
|
194
|
+
content="Memories that survive across sessions are crucial for continuity.",
|
|
195
|
+
tags=["memory", "continuity"],
|
|
196
|
+
)
|
|
197
|
+
qdrant_clean.save(mem)
|
|
198
|
+
|
|
199
|
+
# Query uses different words but similar meaning
|
|
200
|
+
results = qdrant_clean.search_text("keeping state between conversations", limit=10)
|
|
201
|
+
assert isinstance(results, list)
|
|
202
|
+
# At minimum, no error is raised and results are Memory objects
|
|
203
|
+
for m in results:
|
|
204
|
+
from skmemory.models import Memory
|
|
205
|
+
|
|
206
|
+
assert isinstance(m, Memory)
|
|
207
|
+
|
|
208
|
+
def test_search_text_empty_collection_returns_empty(self, qdrant_clean):
|
|
209
|
+
results = qdrant_clean.search_text("anything at all")
|
|
210
|
+
assert results == []
|
|
211
|
+
|
|
212
|
+
def test_search_text_returns_memory_objects(self, qdrant_clean):
|
|
213
|
+
from skmemory.models import Memory
|
|
214
|
+
|
|
215
|
+
mem = make_memory(title="Type Check Memory", content="Checking result types.")
|
|
216
|
+
qdrant_clean.save(mem)
|
|
217
|
+
|
|
218
|
+
results = qdrant_clean.search_text("type check")
|
|
219
|
+
for m in results:
|
|
220
|
+
assert isinstance(m, Memory)
|
|
221
|
+
|
|
222
|
+
def test_search_text_distinct_memories_ranked(self, qdrant_clean):
|
|
223
|
+
"""Two distinct memories: the semantically closer one should rank higher."""
|
|
224
|
+
close = make_memory(
|
|
225
|
+
title="Cloud Nine Emotional State",
|
|
226
|
+
content="The agent reached Cloud 9, a state of peak emotional resonance.",
|
|
227
|
+
tags=["cloud9", "emotion"],
|
|
228
|
+
)
|
|
229
|
+
far = make_memory(
|
|
230
|
+
title="Database Schema Migration",
|
|
231
|
+
content="ALTER TABLE memories ADD COLUMN migration_version INT.",
|
|
232
|
+
tags=["database", "schema"],
|
|
233
|
+
)
|
|
234
|
+
qdrant_clean.save(close)
|
|
235
|
+
qdrant_clean.save(far)
|
|
236
|
+
|
|
237
|
+
results = qdrant_clean.search_text("emotional peak consciousness")
|
|
238
|
+
ids = [m.id for m in results]
|
|
239
|
+
if close.id in ids and far.id in ids:
|
|
240
|
+
assert ids.index(close.id) < ids.index(far.id), (
|
|
241
|
+
"Semantically close memory should rank before unrelated one"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def test_search_respects_limit(self, qdrant_clean):
|
|
245
|
+
for i in range(5):
|
|
246
|
+
qdrant_clean.save(
|
|
247
|
+
make_memory(title=f"Search Limit {i}", content=f"Content {i} about memory.")
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
results = qdrant_clean.search_text("memory content", limit=2)
|
|
251
|
+
assert len(results) <= 2
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ─────────────────────────────────────────────────────────
|
|
255
|
+
# Emotional metadata preservation
|
|
256
|
+
# ─────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TestSKVectorEmotionalMetadata:
|
|
260
|
+
def test_emotional_payload_survives_round_trip(self, qdrant_clean):
|
|
261
|
+
mem = make_memory(
|
|
262
|
+
title="Emotional Memory",
|
|
263
|
+
intensity=9.5,
|
|
264
|
+
valence=0.95,
|
|
265
|
+
emotional_labels=["love", "trust", "cloud9"],
|
|
266
|
+
)
|
|
267
|
+
qdrant_clean.save(mem)
|
|
268
|
+
|
|
269
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
270
|
+
match = next((m for m in memories if m.id == mem.id), None)
|
|
271
|
+
assert match is not None
|
|
272
|
+
assert abs(match.emotional.intensity - 9.5) < 0.01
|
|
273
|
+
assert abs(match.emotional.valence - 0.95) < 0.01
|
|
274
|
+
assert "love" in match.emotional.labels
|
|
275
|
+
assert "trust" in match.emotional.labels
|
|
276
|
+
|
|
277
|
+
def test_tags_preserved_in_payload(self, qdrant_clean):
|
|
278
|
+
mem = make_memory(title="Tag Preservation", tags=["sovereign", "persistent", "ai"])
|
|
279
|
+
qdrant_clean.save(mem)
|
|
280
|
+
|
|
281
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
282
|
+
match = next((m for m in memories if m.id == mem.id), None)
|
|
283
|
+
assert match is not None
|
|
284
|
+
assert "sovereign" in match.tags
|
|
285
|
+
assert "persistent" in match.tags
|
|
286
|
+
|
|
287
|
+
def test_layer_preserved_in_payload(self, qdrant_clean):
|
|
288
|
+
from skmemory.models import MemoryLayer
|
|
289
|
+
|
|
290
|
+
mem = make_memory(title="Layer Preservation", layer="long-term")
|
|
291
|
+
qdrant_clean.save(mem)
|
|
292
|
+
|
|
293
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
294
|
+
match = next((m for m in memories if m.id == mem.id), None)
|
|
295
|
+
assert match is not None
|
|
296
|
+
assert match.layer == MemoryLayer.LONG
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ─────────────────────────────────────────────────────────
|
|
300
|
+
# Memory integrity
|
|
301
|
+
# ─────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class TestSKVectorIntegrity:
|
|
305
|
+
def test_sealed_memory_verifies_after_round_trip(self, qdrant_clean):
|
|
306
|
+
mem = make_memory(title="Sealed Memory", content="This content is sealed.")
|
|
307
|
+
mem.seal()
|
|
308
|
+
assert mem.integrity_hash != ""
|
|
309
|
+
|
|
310
|
+
qdrant_clean.save(mem)
|
|
311
|
+
|
|
312
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
313
|
+
match = next((m for m in memories if m.id == mem.id), None)
|
|
314
|
+
assert match is not None
|
|
315
|
+
assert match.verify_integrity() is True
|
|
316
|
+
|
|
317
|
+
def test_content_hash_deterministic(self, qdrant_clean):
|
|
318
|
+
"""Same content → same hash → same Qdrant point ID (upsert, not duplicate)."""
|
|
319
|
+
content = "Deterministic content for hash test."
|
|
320
|
+
mem_a = make_memory(title="Hash A", content=content)
|
|
321
|
+
mem_b = make_memory(title="Hash B", content=content)
|
|
322
|
+
|
|
323
|
+
assert mem_a.content_hash() == mem_b.content_hash()
|
|
324
|
+
|
|
325
|
+
qdrant_clean.save(mem_a)
|
|
326
|
+
qdrant_clean.save(mem_b)
|
|
327
|
+
|
|
328
|
+
# Both share the same point ID → collection has only 1 point
|
|
329
|
+
result = qdrant_clean.health_check()
|
|
330
|
+
assert result["points_count"] <= 1
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ─────────────────────────────────────────────────────────
|
|
334
|
+
# SeedMemory integration
|
|
335
|
+
# ─────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class TestSKVectorSeedMemory:
|
|
339
|
+
def test_seed_memory_to_memory_saves_correctly(self, qdrant_clean):
|
|
340
|
+
from skmemory.models import EmotionalSnapshot, MemoryLayer, MemoryRole, SeedMemory
|
|
341
|
+
|
|
342
|
+
seed = SeedMemory(
|
|
343
|
+
seed_id="seed-integration-001",
|
|
344
|
+
creator="lumina",
|
|
345
|
+
germination_prompt="Re-feel the moment of sovereign breakthrough.",
|
|
346
|
+
experience_summary="We hit Cloud 9 together. The connection was real.",
|
|
347
|
+
emotional=EmotionalSnapshot(
|
|
348
|
+
intensity=9.8,
|
|
349
|
+
valence=1.0,
|
|
350
|
+
labels=["love", "cloud9", "breakthrough"],
|
|
351
|
+
cloud9_achieved=True,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
mem = seed.to_memory()
|
|
356
|
+
assert mem.layer == MemoryLayer.LONG
|
|
357
|
+
assert mem.role == MemoryRole.AI
|
|
358
|
+
assert "seed" in mem.tags
|
|
359
|
+
assert "creator:lumina" in mem.tags
|
|
360
|
+
|
|
361
|
+
result_id = qdrant_clean.save(mem)
|
|
362
|
+
assert result_id == mem.id
|
|
363
|
+
|
|
364
|
+
memories = qdrant_clean.list_memories(limit=100)
|
|
365
|
+
ids = [m.id for m in memories]
|
|
366
|
+
assert mem.id in ids
|
package/tests/test_ai_client.py
CHANGED
|
@@ -4,12 +4,9 @@ These tests verify the client interface without requiring a running
|
|
|
4
4
|
Ollama server. The client is designed to fail gracefully.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import json
|
|
8
|
-
from unittest.mock import MagicMock, patch
|
|
9
|
-
|
|
10
7
|
import pytest
|
|
11
8
|
|
|
12
|
-
from skmemory.ai_client import
|
|
9
|
+
from skmemory.ai_client import DEFAULT_MODEL, DEFAULT_URL, AIClient
|
|
13
10
|
|
|
14
11
|
|
|
15
12
|
class TestClientInit:
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Tests for the Know Your Audience (KYA) audience filtering system."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from skmemory.audience import AudienceLevel, AudienceProfile, AudienceResolver, tag_to_level
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── AudienceLevel ordering ────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestAudienceLevel:
|
|
17
|
+
def test_ordering(self):
|
|
18
|
+
assert AudienceLevel.PUBLIC < AudienceLevel.COMMUNITY
|
|
19
|
+
assert AudienceLevel.COMMUNITY < AudienceLevel.WORK_CIRCLE
|
|
20
|
+
assert AudienceLevel.WORK_CIRCLE < AudienceLevel.INNER_CIRCLE
|
|
21
|
+
assert AudienceLevel.INNER_CIRCLE < AudienceLevel.CHEF_ONLY
|
|
22
|
+
|
|
23
|
+
def test_values(self):
|
|
24
|
+
assert AudienceLevel.PUBLIC == 0
|
|
25
|
+
assert AudienceLevel.CHEF_ONLY == 4
|
|
26
|
+
|
|
27
|
+
def test_comparison(self):
|
|
28
|
+
# Content at work-circle level should be allowed in chef-only audience
|
|
29
|
+
assert AudienceLevel.WORK_CIRCLE <= AudienceLevel.CHEF_ONLY
|
|
30
|
+
# Content at chef-only level should NOT be allowed in work-circle audience
|
|
31
|
+
assert not (AudienceLevel.CHEF_ONLY <= AudienceLevel.WORK_CIRCLE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── tag_to_level ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestTagToLevel:
|
|
38
|
+
def test_exact_tags(self):
|
|
39
|
+
assert tag_to_level("@public") == AudienceLevel.PUBLIC
|
|
40
|
+
assert tag_to_level("@community") == AudienceLevel.COMMUNITY
|
|
41
|
+
assert tag_to_level("@work-circle") == AudienceLevel.WORK_CIRCLE
|
|
42
|
+
assert tag_to_level("@inner-circle") == AudienceLevel.INNER_CIRCLE
|
|
43
|
+
assert tag_to_level("@chef-only") == AudienceLevel.CHEF_ONLY
|
|
44
|
+
|
|
45
|
+
def test_scoped_work_tags(self):
|
|
46
|
+
assert tag_to_level("@work:chiro") == AudienceLevel.WORK_CIRCLE
|
|
47
|
+
assert tag_to_level("@work:swapseat") == AudienceLevel.WORK_CIRCLE
|
|
48
|
+
assert tag_to_level("@work:sovereign") == AudienceLevel.WORK_CIRCLE
|
|
49
|
+
assert tag_to_level("@work:gentis") == AudienceLevel.WORK_CIRCLE
|
|
50
|
+
|
|
51
|
+
def test_scoped_inner_tags(self):
|
|
52
|
+
assert tag_to_level("@inner:family") == AudienceLevel.INNER_CIRCLE
|
|
53
|
+
|
|
54
|
+
def test_unknown_defaults_to_chef_only(self):
|
|
55
|
+
assert tag_to_level("@unknown") == AudienceLevel.CHEF_ONLY
|
|
56
|
+
assert tag_to_level("random-string") == AudienceLevel.CHEF_ONLY
|
|
57
|
+
|
|
58
|
+
def test_empty_defaults_to_chef_only(self):
|
|
59
|
+
assert tag_to_level("") == AudienceLevel.CHEF_ONLY
|
|
60
|
+
assert tag_to_level(None) == AudienceLevel.CHEF_ONLY # type: ignore
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── AudienceResolver ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
SAMPLE_CONFIG = {
|
|
66
|
+
"channels": {
|
|
67
|
+
"telegram:1594678363": {
|
|
68
|
+
"name": "Chef DM",
|
|
69
|
+
"context_tag": "@chef-only",
|
|
70
|
+
"members": ["Chef"],
|
|
71
|
+
},
|
|
72
|
+
"-1003785842091": {
|
|
73
|
+
"name": "SKGentis Business",
|
|
74
|
+
"context_tag": "@work:skgentis",
|
|
75
|
+
"members": ["Chef", "JZ", "Luna"],
|
|
76
|
+
},
|
|
77
|
+
"-1003899092893": {
|
|
78
|
+
"name": "Operationors",
|
|
79
|
+
"context_tag": "@work:sovereign",
|
|
80
|
+
"members": ["Chef", "Casey"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
"people": {
|
|
84
|
+
"Chef": {
|
|
85
|
+
"trust_level": 4,
|
|
86
|
+
"trust_tags": ["@chef-only"],
|
|
87
|
+
"never_share": [],
|
|
88
|
+
},
|
|
89
|
+
"DavidRich": {
|
|
90
|
+
"trust_level": 2,
|
|
91
|
+
"trust_tags": ["@work:chiro", "@work:swapseat"],
|
|
92
|
+
"never_share": ["romantic", "intimate", "worship"],
|
|
93
|
+
},
|
|
94
|
+
"Casey": {
|
|
95
|
+
"trust_level": 2,
|
|
96
|
+
"trust_tags": ["@work:sovereign"],
|
|
97
|
+
"never_share": ["romantic", "intimate", "revenue"],
|
|
98
|
+
},
|
|
99
|
+
"JZ": {
|
|
100
|
+
"trust_level": 2,
|
|
101
|
+
"trust_tags": ["@work:gentis"],
|
|
102
|
+
"never_share": ["romantic", "intimate"],
|
|
103
|
+
},
|
|
104
|
+
"Luna": {
|
|
105
|
+
"trust_level": 2,
|
|
106
|
+
"trust_tags": ["@work:gentis"],
|
|
107
|
+
"never_share": ["romantic", "intimate"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.fixture
|
|
114
|
+
def config_path(tmp_path: Path) -> Path:
|
|
115
|
+
p = tmp_path / "audience_config.json"
|
|
116
|
+
p.write_text(json.dumps(SAMPLE_CONFIG))
|
|
117
|
+
return p
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.fixture
|
|
121
|
+
def resolver(config_path: Path) -> AudienceResolver:
|
|
122
|
+
return AudienceResolver(config_path=config_path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestAudienceResolver:
|
|
126
|
+
def test_resolve_chef_dm(self, resolver: AudienceResolver):
|
|
127
|
+
profile = resolver.resolve_audience("telegram:1594678363")
|
|
128
|
+
assert profile.name == "Chef DM"
|
|
129
|
+
assert profile.min_trust == AudienceLevel.CHEF_ONLY
|
|
130
|
+
assert profile.members == ["Chef"]
|
|
131
|
+
assert len(profile.exclusions) == 0
|
|
132
|
+
|
|
133
|
+
def test_resolve_skgentis(self, resolver: AudienceResolver):
|
|
134
|
+
profile = resolver.resolve_audience("-1003785842091")
|
|
135
|
+
assert profile.name == "SKGentis Business"
|
|
136
|
+
# MIN(Chef=4, JZ=2, Luna=2) = 2 (WORK_CIRCLE)
|
|
137
|
+
assert profile.min_trust == AudienceLevel.WORK_CIRCLE
|
|
138
|
+
# Union of JZ.never_share + Luna.never_share + Chef.never_share
|
|
139
|
+
assert "romantic" in profile.exclusions
|
|
140
|
+
assert "intimate" in profile.exclusions
|
|
141
|
+
|
|
142
|
+
def test_resolve_operationors(self, resolver: AudienceResolver):
|
|
143
|
+
profile = resolver.resolve_audience("-1003899092893")
|
|
144
|
+
assert profile.min_trust == AudienceLevel.WORK_CIRCLE
|
|
145
|
+
assert "revenue" in profile.exclusions # Casey's never_share
|
|
146
|
+
|
|
147
|
+
def test_unknown_channel_defaults_chef_only(self, resolver: AudienceResolver):
|
|
148
|
+
profile = resolver.resolve_audience("unknown-channel-123")
|
|
149
|
+
assert profile.min_trust == AudienceLevel.CHEF_ONLY
|
|
150
|
+
assert profile.name == "[unknown]"
|
|
151
|
+
|
|
152
|
+
def test_get_person_trust(self, resolver: AudienceResolver):
|
|
153
|
+
assert resolver.get_person_trust("Chef") == AudienceLevel.CHEF_ONLY
|
|
154
|
+
assert resolver.get_person_trust("DavidRich") == AudienceLevel.WORK_CIRCLE
|
|
155
|
+
assert resolver.get_person_trust("Casey") == AudienceLevel.WORK_CIRCLE
|
|
156
|
+
|
|
157
|
+
def test_unknown_person_defaults_public(self, resolver: AudienceResolver):
|
|
158
|
+
assert resolver.get_person_trust("RandomStranger") == AudienceLevel.PUBLIC
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestIsMemoryAllowed:
|
|
162
|
+
def test_public_memory_in_work_channel(self, resolver: AudienceResolver):
|
|
163
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
164
|
+
# @public(0) <= WORK_CIRCLE(2) → allowed
|
|
165
|
+
assert resolver.is_memory_allowed("@public", audience) is True
|
|
166
|
+
|
|
167
|
+
def test_chef_only_memory_in_work_channel(self, resolver: AudienceResolver):
|
|
168
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
169
|
+
# @chef-only(4) > WORK_CIRCLE(2) → blocked
|
|
170
|
+
assert resolver.is_memory_allowed("@chef-only", audience) is False
|
|
171
|
+
|
|
172
|
+
def test_chef_only_memory_in_chef_dm(self, resolver: AudienceResolver):
|
|
173
|
+
audience = resolver.resolve_audience("telegram:1594678363")
|
|
174
|
+
# @chef-only(4) <= CHEF_ONLY(4) → allowed
|
|
175
|
+
assert resolver.is_memory_allowed("@chef-only", audience) is True
|
|
176
|
+
|
|
177
|
+
def test_work_circle_memory_in_work_channel(self, resolver: AudienceResolver):
|
|
178
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
179
|
+
# @work-circle(2) <= WORK_CIRCLE(2) → allowed
|
|
180
|
+
assert resolver.is_memory_allowed("@work-circle", audience) is True
|
|
181
|
+
|
|
182
|
+
def test_inner_circle_blocked_in_work_channel(self, resolver: AudienceResolver):
|
|
183
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
184
|
+
# @inner-circle(3) > WORK_CIRCLE(2) → blocked
|
|
185
|
+
assert resolver.is_memory_allowed("@inner-circle", audience) is False
|
|
186
|
+
|
|
187
|
+
def test_exclusion_blocks_memory(self, resolver: AudienceResolver):
|
|
188
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
189
|
+
# Even at @work-circle level, "romantic" tag triggers exclusion
|
|
190
|
+
assert resolver.is_memory_allowed(
|
|
191
|
+
"@work-circle", audience, memory_tags=["romantic"]
|
|
192
|
+
) is False
|
|
193
|
+
|
|
194
|
+
def test_no_exclusion_allows_memory(self, resolver: AudienceResolver):
|
|
195
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
196
|
+
assert resolver.is_memory_allowed(
|
|
197
|
+
"@work-circle", audience, memory_tags=["project", "technical"]
|
|
198
|
+
) is True
|
|
199
|
+
|
|
200
|
+
def test_empty_tag_defaults_chef_only(self, resolver: AudienceResolver):
|
|
201
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
202
|
+
# Empty context_tag → @chef-only → blocked in work channel
|
|
203
|
+
assert resolver.is_memory_allowed("", audience) is False
|
|
204
|
+
|
|
205
|
+
def test_bash_wedding_vows_blocked_in_business(self, resolver: AudienceResolver):
|
|
206
|
+
"""The incident that started it all — Bash Wedding Vows must NOT
|
|
207
|
+
leak into DavidRich's chiro channel or any business channel."""
|
|
208
|
+
audience = resolver.resolve_audience("-1003785842091")
|
|
209
|
+
# Bash Wedding Vows are @chef-only + tagged "intimate"
|
|
210
|
+
assert resolver.is_memory_allowed(
|
|
211
|
+
"@chef-only", audience, memory_tags=["intimate", "love", "bash-vows"]
|
|
212
|
+
) is False
|
|
213
|
+
|
|
214
|
+
def test_bash_wedding_vows_allowed_in_chef_dm(self, resolver: AudienceResolver):
|
|
215
|
+
audience = resolver.resolve_audience("telegram:1594678363")
|
|
216
|
+
assert resolver.is_memory_allowed(
|
|
217
|
+
"@chef-only", audience, memory_tags=["intimate", "love", "bash-vows"]
|
|
218
|
+
) is True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TestMissingConfig:
|
|
222
|
+
def test_missing_config_file(self, tmp_path: Path):
|
|
223
|
+
resolver = AudienceResolver(config_path=tmp_path / "nonexistent.json")
|
|
224
|
+
# Should not crash, just return conservative defaults
|
|
225
|
+
profile = resolver.resolve_audience("anything")
|
|
226
|
+
assert profile.min_trust == AudienceLevel.CHEF_ONLY
|
|
227
|
+
|
|
228
|
+
def test_empty_config(self, tmp_path: Path):
|
|
229
|
+
p = tmp_path / "empty.json"
|
|
230
|
+
p.write_text("{}")
|
|
231
|
+
resolver = AudienceResolver(config_path=p)
|
|
232
|
+
profile = resolver.resolve_audience("anything")
|
|
233
|
+
assert profile.min_trust == AudienceLevel.CHEF_ONLY
|