@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,228 @@
1
+ """
2
+ Memory Vault — at-rest encryption for sovereign memories.
3
+
4
+ Encrypts memory JSON files using AES-256-GCM with keys derived from
5
+ the agent's passphrase or CapAuth PGP key. Each memory file gets
6
+ its own random nonce, so identical content produces different ciphertext.
7
+
8
+ Quantum-resistant note: AES-256 requires Grover's algorithm to attack,
9
+ which only reduces effective security from 256 to 128 bits. That's
10
+ still computationally infeasible for the foreseeable future.
11
+
12
+ Usage:
13
+ vault = MemoryVault(passphrase="YOUR_PASSPHRASE_HERE")
14
+ encrypted = vault.encrypt(memory_json_bytes)
15
+ decrypted = vault.decrypt(encrypted)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import hashlib
22
+ import json
23
+ import logging
24
+ import os
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ logger = logging.getLogger("skmemory.vault")
29
+
30
+ VAULT_HEADER = b"SKMV1"
31
+ NONCE_SIZE = 12
32
+ TAG_SIZE = 16
33
+ KEY_SIZE = 32
34
+ SALT_SIZE = 16
35
+ KDF_ITERATIONS = 600_000
36
+
37
+
38
+ def _derive_key(passphrase: str, salt: bytes) -> bytes:
39
+ """Derive a 256-bit AES key from a passphrase using PBKDF2.
40
+
41
+ Args:
42
+ passphrase: The encryption passphrase.
43
+ salt: 16-byte random salt.
44
+
45
+ Returns:
46
+ 32-byte AES key.
47
+ """
48
+ return hashlib.pbkdf2_hmac(
49
+ "sha256",
50
+ passphrase.encode("utf-8"),
51
+ salt,
52
+ KDF_ITERATIONS,
53
+ dklen=KEY_SIZE,
54
+ )
55
+
56
+
57
+ class MemoryVault:
58
+ """Encrypt and decrypt memory files at rest.
59
+
60
+ Uses AES-256-GCM for authenticated encryption. Each encrypt()
61
+ call generates a fresh random nonce and salt, so the same
62
+ plaintext never produces the same ciphertext.
63
+
64
+ File format:
65
+ SKMV1 || salt(16) || nonce(12) || ciphertext || tag(16)
66
+
67
+ Args:
68
+ passphrase: Secret used to derive the encryption key.
69
+ Can be the agent's CapAuth passphrase or a dedicated vault key.
70
+ """
71
+
72
+ def __init__(self, passphrase: str) -> None:
73
+ self._passphrase = passphrase
74
+
75
+ def encrypt(self, plaintext: bytes) -> bytes:
76
+ """Encrypt data with AES-256-GCM.
77
+
78
+ Args:
79
+ plaintext: Raw bytes to encrypt (typically memory JSON).
80
+
81
+ Returns:
82
+ Encrypted bytes with header, salt, nonce, ciphertext, and tag.
83
+
84
+ Raises:
85
+ ImportError: If cryptography package is not installed.
86
+ """
87
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
88
+
89
+ salt = os.urandom(SALT_SIZE)
90
+ nonce = os.urandom(NONCE_SIZE)
91
+ key = _derive_key(self._passphrase, salt)
92
+
93
+ aesgcm = AESGCM(key)
94
+ ciphertext = aesgcm.encrypt(nonce, plaintext, VAULT_HEADER)
95
+
96
+ return VAULT_HEADER + salt + nonce + ciphertext
97
+
98
+ def decrypt(self, encrypted: bytes) -> bytes:
99
+ """Decrypt data encrypted with encrypt().
100
+
101
+ Args:
102
+ encrypted: Bytes from encrypt() — header + salt + nonce + ciphertext + tag.
103
+
104
+ Returns:
105
+ Decrypted plaintext bytes.
106
+
107
+ Raises:
108
+ ValueError: If the header doesn't match or decryption fails.
109
+ ImportError: If cryptography package is not installed.
110
+ """
111
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
112
+
113
+ header_len = len(VAULT_HEADER)
114
+ if encrypted[:header_len] != VAULT_HEADER:
115
+ raise ValueError("Not an SKMemory vault file (bad header)")
116
+
117
+ offset = header_len
118
+ salt = encrypted[offset : offset + SALT_SIZE]
119
+ offset += SALT_SIZE
120
+ nonce = encrypted[offset : offset + NONCE_SIZE]
121
+ offset += NONCE_SIZE
122
+ ciphertext = encrypted[offset:]
123
+
124
+ key = _derive_key(self._passphrase, salt)
125
+ aesgcm = AESGCM(key)
126
+
127
+ return aesgcm.decrypt(nonce, ciphertext, VAULT_HEADER)
128
+
129
+ def encrypt_file(self, path: Path) -> Path:
130
+ """Encrypt a file in-place, adding .vault extension.
131
+
132
+ Args:
133
+ path: Path to the plaintext file.
134
+
135
+ Returns:
136
+ Path to the encrypted file.
137
+ """
138
+ plaintext = path.read_bytes()
139
+ encrypted = self.encrypt(plaintext)
140
+ vault_path = path.with_suffix(path.suffix + ".vault")
141
+ vault_path.write_bytes(encrypted)
142
+ path.unlink()
143
+ return vault_path
144
+
145
+ def decrypt_file(self, path: Path) -> Path:
146
+ """Decrypt a .vault file, restoring the original.
147
+
148
+ Args:
149
+ path: Path to the encrypted .vault file.
150
+
151
+ Returns:
152
+ Path to the decrypted file.
153
+ """
154
+ encrypted = path.read_bytes()
155
+ plaintext = self.decrypt(encrypted)
156
+ original_path = Path(str(path).replace(".vault", ""))
157
+ original_path.write_bytes(plaintext)
158
+ path.unlink()
159
+ return original_path
160
+
161
+ def is_encrypted(self, path: Path) -> bool:
162
+ """Check if a file is vault-encrypted.
163
+
164
+ Args:
165
+ path: Path to check.
166
+
167
+ Returns:
168
+ True if the file has a valid vault header.
169
+ """
170
+ try:
171
+ data = path.read_bytes()
172
+ return data[:len(VAULT_HEADER)] == VAULT_HEADER
173
+ except OSError:
174
+ return False
175
+
176
+
177
+ def encrypt_memory_store(
178
+ memory_dir: Path,
179
+ passphrase: str,
180
+ ) -> int:
181
+ """Encrypt all memory JSON files in a directory tree.
182
+
183
+ Args:
184
+ memory_dir: Root memory directory (e.g., ~/.skcapstone/memories/).
185
+ passphrase: Encryption passphrase.
186
+
187
+ Returns:
188
+ Number of files encrypted.
189
+ """
190
+ vault = MemoryVault(passphrase)
191
+ count = 0
192
+
193
+ for json_file in memory_dir.rglob("*.json"):
194
+ if json_file.suffix == ".vault":
195
+ continue
196
+ try:
197
+ vault.encrypt_file(json_file)
198
+ count += 1
199
+ except Exception as exc:
200
+ logger.warning("Failed to encrypt %s: %s", json_file, exc)
201
+
202
+ return count
203
+
204
+
205
+ def decrypt_memory_store(
206
+ memory_dir: Path,
207
+ passphrase: str,
208
+ ) -> int:
209
+ """Decrypt all vault files in a directory tree.
210
+
211
+ Args:
212
+ memory_dir: Root memory directory.
213
+ passphrase: Decryption passphrase.
214
+
215
+ Returns:
216
+ Number of files decrypted.
217
+ """
218
+ vault = MemoryVault(passphrase)
219
+ count = 0
220
+
221
+ for vault_file in memory_dir.rglob("*.vault"):
222
+ try:
223
+ vault.decrypt_file(vault_file)
224
+ count += 1
225
+ except Exception as exc:
226
+ logger.warning("Failed to decrypt %s: %s", vault_file, exc)
227
+
228
+ return count
File without changes
@@ -0,0 +1,233 @@
1
+ """
2
+ Integration test fixtures for SKMemory live backends.
3
+
4
+ Fixtures skip automatically when SKGraph or SKVector are unreachable.
5
+ Set env vars to point at non-default endpoints:
6
+
7
+ SKMEMORY_SKGRAPH_URL=redis://localhost:6379
8
+ SKMEMORY_SKVECTOR_URL=http://localhost:6333
9
+
10
+ A dedicated test graph and collection are used so production data is
11
+ never touched. Both are torn down after the test session.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import uuid
18
+
19
+ import pytest
20
+
21
+ # ─────────────────────────────────────────────────────────
22
+ # Connection constants
23
+ # ─────────────────────────────────────────────────────────
24
+
25
+ SKGRAPH_URL = os.environ.get("SKMEMORY_SKGRAPH_URL", "redis://localhost:6379")
26
+ SKVECTOR_URL = os.environ.get("SKMEMORY_SKVECTOR_URL", "http://localhost:6333")
27
+ SKVECTOR_KEY = os.environ.get("SKMEMORY_SKVECTOR_KEY")
28
+
29
+ # Isolated names so tests never collide with production data
30
+ TEST_GRAPH_NAME = "skmemory_integration_test"
31
+ TEST_COLLECTION_NAME = "skmemory_integration_test"
32
+
33
+
34
+ # ─────────────────────────────────────────────────────────
35
+ # Availability checks
36
+ # ─────────────────────────────────────────────────────────
37
+
38
+
39
+ def _skgraph_available() -> bool:
40
+ """Return True if a SKGraph (FalkorDB/Redis) server is reachable."""
41
+ try:
42
+ from falkordb import FalkorDB # type: ignore[import]
43
+ except ImportError:
44
+ return False
45
+
46
+ try:
47
+ db = FalkorDB.from_url(SKGRAPH_URL)
48
+ g = db.select_graph("__ping__")
49
+ g.query("RETURN 1")
50
+ return True
51
+ except Exception:
52
+ return False
53
+
54
+
55
+ def _skvector_available() -> bool:
56
+ """Return True if SKVector (Qdrant) is reachable and qdrant-client is installed."""
57
+ try:
58
+ from qdrant_client import QdrantClient # type: ignore[import]
59
+ except ImportError:
60
+ return False
61
+
62
+ try:
63
+ client = QdrantClient(url=SKVECTOR_URL, api_key=SKVECTOR_KEY)
64
+ client.get_collections()
65
+ return True
66
+ except Exception:
67
+ return False
68
+
69
+
70
+ def _sentence_transformers_available() -> bool:
71
+ try:
72
+ import sentence_transformers # noqa: F401 # type: ignore[import]
73
+ return True
74
+ except ImportError:
75
+ return False
76
+
77
+
78
+ SKGRAPH_AVAILABLE = _skgraph_available()
79
+ SKVECTOR_AVAILABLE = _skvector_available()
80
+ SENTENCE_TRANSFORMERS_AVAILABLE = _sentence_transformers_available()
81
+
82
+ # Composite flag: SKVector tests also require the embedding model
83
+ SKVECTOR_FULL_AVAILABLE = SKVECTOR_AVAILABLE and SENTENCE_TRANSFORMERS_AVAILABLE
84
+
85
+ requires_skgraph = pytest.mark.skipif(
86
+ not SKGRAPH_AVAILABLE,
87
+ reason="SKGraph unreachable (set SKMEMORY_SKGRAPH_URL or start Redis+FalkorDB)",
88
+ )
89
+
90
+ requires_skvector = pytest.mark.skipif(
91
+ not SKVECTOR_FULL_AVAILABLE,
92
+ reason=(
93
+ "SKVector unreachable or sentence-transformers not installed "
94
+ "(set SKMEMORY_SKVECTOR_URL or pip install qdrant-client sentence-transformers)"
95
+ ),
96
+ )
97
+
98
+ requires_both = pytest.mark.skipif(
99
+ not (SKGRAPH_AVAILABLE and SKVECTOR_FULL_AVAILABLE),
100
+ reason="Both SKGraph and SKVector must be reachable for cross-backend tests",
101
+ )
102
+
103
+
104
+ # ─────────────────────────────────────────────────────────
105
+ # SKGraph fixtures
106
+ # ─────────────────────────────────────────────────────────
107
+
108
+
109
+ @pytest.fixture(scope="session")
110
+ def falkordb_backend():
111
+ """Live SKGraphBackend pointed at the test graph.
112
+
113
+ The test graph is deleted at session teardown.
114
+ """
115
+ pytest.importorskip("falkordb", reason="falkordb not installed")
116
+ if not SKGRAPH_AVAILABLE:
117
+ pytest.skip("SKGraph unreachable")
118
+
119
+ from skmemory.backends.skgraph_backend import SKGraphBackend
120
+
121
+ backend = SKGraphBackend(url=SKGRAPH_URL, graph_name=TEST_GRAPH_NAME)
122
+ assert backend._ensure_initialized(), "SKGraph backend failed to initialize"
123
+
124
+ yield backend
125
+
126
+ # Teardown: drop the test graph
127
+ try:
128
+ from falkordb import FalkorDB # type: ignore[import]
129
+ db = FalkorDB.from_url(SKGRAPH_URL)
130
+ db.select_graph(TEST_GRAPH_NAME).delete()
131
+ except Exception:
132
+ pass
133
+
134
+
135
+ @pytest.fixture
136
+ def falkordb_clean(falkordb_backend):
137
+ """SKGraphBackend with test graph wiped before each test."""
138
+ # Clear all nodes so tests are independent
139
+ try:
140
+ falkordb_backend._graph.query("MATCH (n) DETACH DELETE n")
141
+ except Exception:
142
+ pass
143
+ return falkordb_backend
144
+
145
+
146
+ # ─────────────────────────────────────────────────────────
147
+ # SKVector fixtures
148
+ # ─────────────────────────────────────────────────────────
149
+
150
+
151
+ @pytest.fixture(scope="session")
152
+ def qdrant_backend():
153
+ """Live SKVectorBackend pointed at the test collection.
154
+
155
+ The test collection is deleted at session teardown.
156
+ """
157
+ pytest.importorskip("qdrant_client", reason="qdrant-client not installed")
158
+ pytest.importorskip("sentence_transformers", reason="sentence-transformers not installed")
159
+ if not SKVECTOR_AVAILABLE:
160
+ pytest.skip("SKVector unreachable")
161
+
162
+ from skmemory.backends.skvector_backend import SKVectorBackend
163
+
164
+ backend = SKVectorBackend(
165
+ url=SKVECTOR_URL,
166
+ api_key=SKVECTOR_KEY,
167
+ collection=TEST_COLLECTION_NAME,
168
+ )
169
+ assert backend._ensure_initialized(), "SKVector backend failed to initialize"
170
+
171
+ yield backend
172
+
173
+ # Teardown: delete test collection
174
+ try:
175
+ backend._client.delete_collection(TEST_COLLECTION_NAME)
176
+ except Exception:
177
+ pass
178
+
179
+
180
+ @pytest.fixture
181
+ def qdrant_clean(qdrant_backend):
182
+ """SKVectorBackend with collection wiped before each test."""
183
+ try:
184
+ from qdrant_client.models import Distance, VectorParams
185
+ from skmemory.backends.skvector_backend import VECTOR_DIM
186
+
187
+ qdrant_backend._client.delete_collection(TEST_COLLECTION_NAME)
188
+ qdrant_backend._client.create_collection(
189
+ collection_name=TEST_COLLECTION_NAME,
190
+ vectors_config=VectorParams(size=VECTOR_DIM, distance=Distance.COSINE),
191
+ )
192
+ except Exception:
193
+ pass
194
+ return qdrant_backend
195
+
196
+
197
+ # ─────────────────────────────────────────────────────────
198
+ # Shared memory factory
199
+ # ─────────────────────────────────────────────────────────
200
+
201
+
202
+ def make_memory(
203
+ title: str = "Test Memory",
204
+ content: str = "Integration test content.",
205
+ tags: list[str] | None = None,
206
+ source: str = "integration-test",
207
+ layer: str = "short-term",
208
+ intensity: float = 5.0,
209
+ valence: float = 0.5,
210
+ emotional_labels: list[str] | None = None,
211
+ parent_id: str | None = None,
212
+ related_ids: list[str] | None = None,
213
+ ):
214
+ """Factory for Memory objects in integration tests."""
215
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
216
+
217
+ layer_enum = MemoryLayer(layer)
218
+ emotional = EmotionalSnapshot(
219
+ intensity=intensity,
220
+ valence=valence,
221
+ labels=emotional_labels or [],
222
+ )
223
+ return Memory(
224
+ id=str(uuid.uuid4()),
225
+ title=title,
226
+ content=content,
227
+ tags=tags or [],
228
+ source=source,
229
+ layer=layer_enum,
230
+ emotional=emotional,
231
+ parent_id=parent_id,
232
+ related_ids=related_ids or [],
233
+ )