@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,250 @@
|
|
|
1
|
+
"""Tests for cross-agent memory sharing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pgpy
|
|
8
|
+
import pytest
|
|
9
|
+
from pgpy.constants import (
|
|
10
|
+
HashAlgorithm,
|
|
11
|
+
KeyFlags,
|
|
12
|
+
PubKeyAlgorithm,
|
|
13
|
+
SymmetricKeyAlgorithm,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
|
|
17
|
+
from skmemory.sharing import MemorySharer, ShareBundle, ShareFilter
|
|
18
|
+
from skmemory.store import MemoryStore
|
|
19
|
+
|
|
20
|
+
PASSPHRASE = "share-test-2026"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _generate_keypair() -> tuple[str, str]:
|
|
24
|
+
"""Generate a test RSA-2048 keypair."""
|
|
25
|
+
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
|
26
|
+
uid = pgpy.PGPUID.new("ShareTest", email="share@test.io")
|
|
27
|
+
key.add_uid(
|
|
28
|
+
uid,
|
|
29
|
+
usage={KeyFlags.Sign, KeyFlags.Certify},
|
|
30
|
+
hashes=[HashAlgorithm.SHA256],
|
|
31
|
+
ciphers=[SymmetricKeyAlgorithm.AES256],
|
|
32
|
+
)
|
|
33
|
+
enc_sub = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
|
34
|
+
key.add_subkey(
|
|
35
|
+
enc_sub,
|
|
36
|
+
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
|
37
|
+
)
|
|
38
|
+
key.protect(PASSPHRASE, SymmetricKeyAlgorithm.AES256, HashAlgorithm.SHA256)
|
|
39
|
+
return str(key), str(key.pubkey)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture(scope="session")
|
|
43
|
+
def recipient_keys() -> tuple[str, str]:
|
|
44
|
+
"""Recipient keypair for encryption tests."""
|
|
45
|
+
return _generate_keypair()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture()
|
|
49
|
+
def store(tmp_path: Path) -> MemoryStore:
|
|
50
|
+
"""Fresh MemoryStore with test data."""
|
|
51
|
+
from skmemory.backends.file_backend import FileBackend
|
|
52
|
+
|
|
53
|
+
backend = FileBackend(base_path=tmp_path / "memories")
|
|
54
|
+
s = MemoryStore(primary=backend)
|
|
55
|
+
|
|
56
|
+
s.snapshot(
|
|
57
|
+
title="Project breakthrough",
|
|
58
|
+
content="Achieved full sovereign messaging",
|
|
59
|
+
tags=["project", "milestone"],
|
|
60
|
+
layer=MemoryLayer.MID,
|
|
61
|
+
emotional=EmotionalSnapshot(intensity=8.0, valence=0.9, labels=["pride"]),
|
|
62
|
+
)
|
|
63
|
+
s.snapshot(
|
|
64
|
+
title="Daily standup",
|
|
65
|
+
content="Routine sync meeting notes",
|
|
66
|
+
tags=["daily", "routine"],
|
|
67
|
+
layer=MemoryLayer.SHORT,
|
|
68
|
+
emotional=EmotionalSnapshot(intensity=2.0),
|
|
69
|
+
)
|
|
70
|
+
s.snapshot(
|
|
71
|
+
title="Secret key rotation",
|
|
72
|
+
content="Rotated PGP keys for all agents",
|
|
73
|
+
tags=["security", "private"],
|
|
74
|
+
layer=MemoryLayer.LONG,
|
|
75
|
+
emotional=EmotionalSnapshot(intensity=5.0),
|
|
76
|
+
)
|
|
77
|
+
return s
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.fixture()
|
|
81
|
+
def sharer(store: MemoryStore) -> MemorySharer:
|
|
82
|
+
"""MemorySharer wired to the test store."""
|
|
83
|
+
return MemorySharer(store=store, identity="capauth:alice@skworld.io")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture()
|
|
87
|
+
def receiver_store(tmp_path: Path) -> MemoryStore:
|
|
88
|
+
"""Separate MemoryStore for the receiving agent."""
|
|
89
|
+
from skmemory.backends.file_backend import FileBackend
|
|
90
|
+
|
|
91
|
+
backend = FileBackend(base_path=tmp_path / "receiver-memories")
|
|
92
|
+
return MemoryStore(primary=backend)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestShareFilter:
|
|
96
|
+
"""Tests for ShareFilter behavior."""
|
|
97
|
+
|
|
98
|
+
def test_empty_filter(self) -> None:
|
|
99
|
+
"""Empty filter reports as empty."""
|
|
100
|
+
sf = ShareFilter()
|
|
101
|
+
assert sf.is_empty() is True
|
|
102
|
+
|
|
103
|
+
def test_filter_with_tags(self) -> None:
|
|
104
|
+
"""Filter with tags is not empty."""
|
|
105
|
+
sf = ShareFilter(tags=["project"])
|
|
106
|
+
assert sf.is_empty() is False
|
|
107
|
+
|
|
108
|
+
def test_filter_with_ids(self) -> None:
|
|
109
|
+
"""Filter with memory_ids is not empty."""
|
|
110
|
+
sf = ShareFilter(memory_ids=["abc"])
|
|
111
|
+
assert sf.is_empty() is False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestExportMemories:
|
|
115
|
+
"""Tests for memory export/selection."""
|
|
116
|
+
|
|
117
|
+
def test_export_by_tags(self, sharer: MemorySharer) -> None:
|
|
118
|
+
"""Export selects memories matching tags."""
|
|
119
|
+
sf = ShareFilter(tags=["project"])
|
|
120
|
+
bundle = sharer.export_memories(sf, recipient="capauth:bob@skworld.io")
|
|
121
|
+
|
|
122
|
+
assert bundle.memory_count >= 1
|
|
123
|
+
assert bundle.sharer == "capauth:alice@skworld.io"
|
|
124
|
+
assert bundle.recipient == "capauth:bob@skworld.io"
|
|
125
|
+
assert bundle.checksum != ""
|
|
126
|
+
|
|
127
|
+
def test_export_by_layer(self, sharer: MemorySharer) -> None:
|
|
128
|
+
"""Export selects memories in specified layers."""
|
|
129
|
+
sf = ShareFilter(layers=[MemoryLayer.MID])
|
|
130
|
+
bundle = sharer.export_memories(sf)
|
|
131
|
+
assert bundle.memory_count >= 1
|
|
132
|
+
|
|
133
|
+
def test_export_with_intensity_filter(self, sharer: MemorySharer) -> None:
|
|
134
|
+
"""Intensity filter excludes low-intensity memories."""
|
|
135
|
+
sf = ShareFilter(tags=["daily", "project", "security"], min_intensity=4.0)
|
|
136
|
+
bundle = sharer.export_memories(sf)
|
|
137
|
+
for mem in bundle.memories:
|
|
138
|
+
assert mem.get("emotional", {}).get("intensity", 0) >= 4.0
|
|
139
|
+
|
|
140
|
+
def test_export_with_exclude_tags(self, sharer: MemorySharer) -> None:
|
|
141
|
+
"""Exclude tags prevent certain memories from being shared."""
|
|
142
|
+
sf = ShareFilter(
|
|
143
|
+
tags=["project", "security"],
|
|
144
|
+
exclude_tags=["private"],
|
|
145
|
+
)
|
|
146
|
+
bundle = sharer.export_memories(sf)
|
|
147
|
+
for mem in bundle.memories:
|
|
148
|
+
assert "private" not in mem.get("tags", [])
|
|
149
|
+
|
|
150
|
+
def test_export_empty_filter_raises(self, sharer: MemorySharer) -> None:
|
|
151
|
+
"""Empty filter raises ValueError for safety."""
|
|
152
|
+
sf = ShareFilter()
|
|
153
|
+
with pytest.raises(ValueError, match="Explicit criteria required"):
|
|
154
|
+
sharer.export_memories(sf)
|
|
155
|
+
|
|
156
|
+
def test_export_max_count(self, sharer: MemorySharer) -> None:
|
|
157
|
+
"""Max count limits the number of exported memories."""
|
|
158
|
+
sf = ShareFilter(tags=["project", "daily", "security"], max_count=1)
|
|
159
|
+
bundle = sharer.export_memories(sf)
|
|
160
|
+
assert bundle.memory_count <= 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestImportBundle:
|
|
164
|
+
"""Tests for memory import."""
|
|
165
|
+
|
|
166
|
+
def test_import_adds_provenance(
|
|
167
|
+
self, sharer: MemorySharer, receiver_store: MemoryStore,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Imported memories have provenance tags."""
|
|
170
|
+
sf = ShareFilter(tags=["project"])
|
|
171
|
+
bundle = sharer.export_memories(sf)
|
|
172
|
+
|
|
173
|
+
receiver = MemorySharer(store=receiver_store, identity="capauth:bob@skworld.io")
|
|
174
|
+
result = receiver.import_bundle(bundle)
|
|
175
|
+
|
|
176
|
+
assert result["imported"] >= 1
|
|
177
|
+
assert result["errors"] == 0
|
|
178
|
+
|
|
179
|
+
imported = receiver_store.list_memories(tags=["shared"])
|
|
180
|
+
assert len(imported) >= 1
|
|
181
|
+
assert any("shared:from:capauth:alice@skworld.io" in m.tags for m in imported)
|
|
182
|
+
|
|
183
|
+
def test_import_untrusted_skips(self, sharer: MemorySharer, receiver_store: MemoryStore) -> None:
|
|
184
|
+
"""Untrusted sharer is rejected."""
|
|
185
|
+
sf = ShareFilter(tags=["project"])
|
|
186
|
+
bundle = sharer.export_memories(sf)
|
|
187
|
+
|
|
188
|
+
receiver = MemorySharer(store=receiver_store)
|
|
189
|
+
result = receiver.import_bundle(bundle, trust_sharer=False)
|
|
190
|
+
assert result["imported"] == 0
|
|
191
|
+
assert result["skipped"] == bundle.memory_count
|
|
192
|
+
|
|
193
|
+
def test_import_checksum_mismatch(self, receiver_store: MemoryStore) -> None:
|
|
194
|
+
"""Tampered bundle fails checksum verification."""
|
|
195
|
+
bundle = ShareBundle(
|
|
196
|
+
sharer="evil",
|
|
197
|
+
memories=[{"title": "fake", "content": "hacked"}],
|
|
198
|
+
memory_count=1,
|
|
199
|
+
checksum="wrong_checksum",
|
|
200
|
+
)
|
|
201
|
+
receiver = MemorySharer(store=receiver_store)
|
|
202
|
+
result = receiver.import_bundle(bundle)
|
|
203
|
+
assert result["errors"] == 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestEncryptDecrypt:
|
|
207
|
+
"""Tests for PGP encryption of share bundles."""
|
|
208
|
+
|
|
209
|
+
def test_encrypt_decrypt_roundtrip(
|
|
210
|
+
self, sharer: MemorySharer, recipient_keys: tuple[str, str],
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Bundle encrypted for recipient can be decrypted."""
|
|
213
|
+
priv, pub = recipient_keys
|
|
214
|
+
sf = ShareFilter(tags=["project"])
|
|
215
|
+
bundle = sharer.export_memories(sf)
|
|
216
|
+
|
|
217
|
+
encrypted = sharer.encrypt_bundle(bundle, pub)
|
|
218
|
+
assert encrypted.encrypted is True
|
|
219
|
+
assert len(encrypted.memories) == 1
|
|
220
|
+
assert "ciphertext" in encrypted.memories[0]
|
|
221
|
+
|
|
222
|
+
receiver = MemorySharer(store=sharer._store)
|
|
223
|
+
decrypted = receiver.decrypt_bundle(encrypted, priv, PASSPHRASE)
|
|
224
|
+
assert decrypted.encrypted is False
|
|
225
|
+
assert decrypted.memory_count == bundle.memory_count
|
|
226
|
+
|
|
227
|
+
def test_decrypt_plaintext_is_noop(self, sharer: MemorySharer) -> None:
|
|
228
|
+
"""Decrypting a non-encrypted bundle returns it unchanged."""
|
|
229
|
+
sf = ShareFilter(tags=["project"])
|
|
230
|
+
bundle = sharer.export_memories(sf)
|
|
231
|
+
|
|
232
|
+
result = sharer.decrypt_bundle(bundle, "key", "pass")
|
|
233
|
+
assert result.encrypted is False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TestBundlePersistence:
|
|
237
|
+
"""Tests for save/load bundle files."""
|
|
238
|
+
|
|
239
|
+
def test_save_and_load(self, sharer: MemorySharer, tmp_path: Path) -> None:
|
|
240
|
+
"""Bundle survives save/load roundtrip."""
|
|
241
|
+
sf = ShareFilter(tags=["project"])
|
|
242
|
+
bundle = sharer.export_memories(sf)
|
|
243
|
+
|
|
244
|
+
filepath = tmp_path / "bundle.json"
|
|
245
|
+
sharer.save_bundle(bundle, filepath)
|
|
246
|
+
|
|
247
|
+
loaded = MemorySharer.load_bundle(filepath)
|
|
248
|
+
assert loaded.bundle_id == bundle.bundle_id
|
|
249
|
+
assert loaded.memory_count == bundle.memory_count
|
|
250
|
+
assert loaded.checksum == bundle.checksum
|