@smilintux/skmemory 0.7.2 → 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 +4 -4
- package/.github/workflows/publish.yml +4 -5
- package/ARCHITECTURE.md +298 -0
- package/CHANGELOG.md +27 -1
- package/README.md +6 -0
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/package.json +1 -1
- package/pyproject.toml +5 -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 +6 -4
- 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 +7 -5
- package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
- package/seeds/lumina-kingdom-founding.seed.json +9 -7
- package/seeds/lumina-pma-signed.seed.json +8 -6
- package/seeds/lumina-singular-achievement.seed.json +8 -6
- package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
- package/seeds/plant-lumina-seeds.py +2 -2
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skmemory/__init__.py +16 -13
- package/skmemory/agents.py +10 -10
- package/skmemory/ai_client.py +10 -21
- package/skmemory/anchor.py +5 -9
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +1 -1
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +18 -13
- package/skmemory/backends/skgraph_backend.py +7 -19
- package/skmemory/backends/skvector_backend.py +7 -18
- package/skmemory/backends/sqlite_backend.py +115 -32
- package/skmemory/backends/vaulted_backend.py +7 -9
- package/skmemory/cli.py +146 -78
- package/skmemory/config.py +11 -13
- package/skmemory/context_loader.py +21 -23
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +36 -31
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +30 -40
- 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/telegram.py +42 -13
- package/skmemory/importers/telegram_api.py +152 -60
- package/skmemory/journal.py +3 -7
- package/skmemory/lovenote.py +4 -11
- package/skmemory/mcp_server.py +182 -29
- package/skmemory/models.py +10 -8
- package/skmemory/openclaw.py +14 -22
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +13 -9
- package/skmemory/promotion.py +48 -24
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +144 -18
- package/skmemory/register_mcp.py +1 -2
- package/skmemory/ritual.py +104 -13
- package/skmemory/seeds.py +21 -26
- package/skmemory/setup_wizard.py +40 -52
- package/skmemory/sharing.py +11 -5
- package/skmemory/soul.py +29 -10
- package/skmemory/steelman.py +43 -17
- package/skmemory/store.py +152 -30
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +2 -5
- package/tests/conftest.py +46 -0
- package/tests/integration/conftest.py +6 -6
- package/tests/integration/test_cross_backend.py +4 -9
- package/tests/integration/test_skgraph_live.py +3 -7
- package/tests/integration/test_skvector_live.py +1 -4
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +5 -14
- package/tests/test_endpoint_selector.py +101 -63
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +6 -5
- package/tests/test_fortress_hardening.py +13 -16
- package/tests/test_openclaw.py +1 -4
- package/tests/test_predictive.py +1 -1
- package/tests/test_promotion.py +10 -3
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +18 -14
- package/tests/test_seeds.py +4 -10
- package/tests/test_setup.py +203 -88
- package/tests/test_sharing.py +15 -8
- package/tests/test_skgraph_backend.py +22 -29
- package/tests/test_skvector_backend.py +2 -2
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +2 -3
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +2 -2
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +4 -3
- package/openclaw-plugin/src/index.ts +0 -255
package/tests/test_sharing.py
CHANGED
|
@@ -4,18 +4,19 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
import pgpy
|
|
8
7
|
import pytest
|
|
9
|
-
|
|
8
|
+
|
|
9
|
+
pgpy = pytest.importorskip("pgpy", reason="pgpy not available or incompatible")
|
|
10
|
+
from pgpy.constants import ( # noqa: E402
|
|
10
11
|
HashAlgorithm,
|
|
11
12
|
KeyFlags,
|
|
12
13
|
PubKeyAlgorithm,
|
|
13
14
|
SymmetricKeyAlgorithm,
|
|
14
15
|
)
|
|
15
16
|
|
|
16
|
-
from skmemory.models import EmotionalSnapshot,
|
|
17
|
-
from skmemory.sharing import MemorySharer, ShareBundle, ShareFilter
|
|
18
|
-
from skmemory.store import MemoryStore
|
|
17
|
+
from skmemory.models import EmotionalSnapshot, MemoryLayer # noqa: E402
|
|
18
|
+
from skmemory.sharing import MemorySharer, ShareBundle, ShareFilter # noqa: E402
|
|
19
|
+
from skmemory.store import MemoryStore # noqa: E402
|
|
19
20
|
|
|
20
21
|
PASSPHRASE = "share-test-2026"
|
|
21
22
|
|
|
@@ -164,7 +165,9 @@ class TestImportBundle:
|
|
|
164
165
|
"""Tests for memory import."""
|
|
165
166
|
|
|
166
167
|
def test_import_adds_provenance(
|
|
167
|
-
self,
|
|
168
|
+
self,
|
|
169
|
+
sharer: MemorySharer,
|
|
170
|
+
receiver_store: MemoryStore,
|
|
168
171
|
) -> None:
|
|
169
172
|
"""Imported memories have provenance tags."""
|
|
170
173
|
sf = ShareFilter(tags=["project"])
|
|
@@ -180,7 +183,9 @@ class TestImportBundle:
|
|
|
180
183
|
assert len(imported) >= 1
|
|
181
184
|
assert any("shared:from:capauth:alice@skworld.io" in m.tags for m in imported)
|
|
182
185
|
|
|
183
|
-
def test_import_untrusted_skips(
|
|
186
|
+
def test_import_untrusted_skips(
|
|
187
|
+
self, sharer: MemorySharer, receiver_store: MemoryStore
|
|
188
|
+
) -> None:
|
|
184
189
|
"""Untrusted sharer is rejected."""
|
|
185
190
|
sf = ShareFilter(tags=["project"])
|
|
186
191
|
bundle = sharer.export_memories(sf)
|
|
@@ -207,7 +212,9 @@ class TestEncryptDecrypt:
|
|
|
207
212
|
"""Tests for PGP encryption of share bundles."""
|
|
208
213
|
|
|
209
214
|
def test_encrypt_decrypt_roundtrip(
|
|
210
|
-
self,
|
|
215
|
+
self,
|
|
216
|
+
sharer: MemorySharer,
|
|
217
|
+
recipient_keys: tuple[str, str],
|
|
211
218
|
) -> None:
|
|
212
219
|
"""Bundle encrypted for recipient can be decrypted."""
|
|
213
220
|
priv, pub = recipient_keys
|
|
@@ -22,14 +22,13 @@ Coverage areas:
|
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
-
from unittest.mock import MagicMock, patch
|
|
25
|
+
from unittest.mock import MagicMock, patch
|
|
26
26
|
|
|
27
27
|
import pytest
|
|
28
28
|
|
|
29
29
|
from skmemory.backends.skgraph_backend import SKGraphBackend
|
|
30
30
|
from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
|
|
31
31
|
|
|
32
|
-
|
|
33
32
|
# ─────────────────────────────────────────────────────────
|
|
34
33
|
# Shared fixtures
|
|
35
34
|
# ─────────────────────────────────────────────────────────
|
|
@@ -89,9 +88,11 @@ class TestInitialization:
|
|
|
89
88
|
def test_lazy_init_without_falkordb(self):
|
|
90
89
|
"""Backend gracefully handles missing falkordb package."""
|
|
91
90
|
fb = SKGraphBackend()
|
|
92
|
-
with
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
with (
|
|
92
|
+
patch.dict("sys.modules", {"falkordb": None}),
|
|
93
|
+
patch("builtins.__import__", side_effect=ImportError("no falkordb")),
|
|
94
|
+
):
|
|
95
|
+
assert fb._ensure_initialized() is False
|
|
95
96
|
|
|
96
97
|
def test_connection_failure_handled(self):
|
|
97
98
|
"""Backend handles connection failure gracefully."""
|
|
@@ -138,10 +139,7 @@ class TestIndexMemory:
|
|
|
138
139
|
calls = [str(c) for c in mock_graph.query.call_args_list]
|
|
139
140
|
# Explicit RELATED_TO edges: one per related_id, passed with b_id param.
|
|
140
141
|
# Exclude the shared-tag auto-wire query (which uses a_id only, no b_id).
|
|
141
|
-
explicit_related = [
|
|
142
|
-
c for c in calls
|
|
143
|
-
if "RELATED_TO" in c and "b_id" in c
|
|
144
|
-
]
|
|
142
|
+
explicit_related = [c for c in calls if "RELATED_TO" in c and "b_id" in c]
|
|
145
143
|
assert len(explicit_related) == len(related_memory.related_ids)
|
|
146
144
|
|
|
147
145
|
def test_index_with_parent_id(self, backend, mock_graph, related_memory):
|
|
@@ -164,10 +162,7 @@ class TestIndexMemory:
|
|
|
164
162
|
calls = [str(c) for c in mock_graph.query.call_args_list]
|
|
165
163
|
# Explicit TAGGED calls use $tag param; exclude the shared-tag sweep
|
|
166
164
|
# (CREATE_SHARED_TAG_RELATED also contains "TAGGED" but uses $a_id only).
|
|
167
|
-
explicit_tagged = [
|
|
168
|
-
c for c in calls
|
|
169
|
-
if "TAGGED" in c and "'tag'" in c
|
|
170
|
-
]
|
|
165
|
+
explicit_tagged = [c for c in calls if "TAGGED" in c and "'tag'" in c]
|
|
171
166
|
assert len(explicit_tagged) == len(sample_memory.tags)
|
|
172
167
|
|
|
173
168
|
def test_index_creates_from_source_edge(self, backend, mock_graph, sample_memory):
|
|
@@ -177,9 +172,7 @@ class TestIndexMemory:
|
|
|
177
172
|
source_calls = [c for c in calls if "FROM_SOURCE" in c]
|
|
178
173
|
assert len(source_calls) >= 1
|
|
179
174
|
|
|
180
|
-
def test_index_creates_preceded_by_edge_when_prior_exists(
|
|
181
|
-
self, backend, mock_graph
|
|
182
|
-
):
|
|
175
|
+
def test_index_creates_preceded_by_edge_when_prior_exists(self, backend, mock_graph):
|
|
183
176
|
"""PRECEDED_BY edge is created when a prior memory from same source exists."""
|
|
184
177
|
# Simulate a previous memory from same source being found
|
|
185
178
|
prior_result = MagicMock()
|
|
@@ -202,9 +195,7 @@ class TestIndexMemory:
|
|
|
202
195
|
def side_effect(query, params=None):
|
|
203
196
|
call_count[0] += 1
|
|
204
197
|
result = MagicMock()
|
|
205
|
-
if "FIND_PREVIOUS_FROM_SOURCE" in query or (
|
|
206
|
-
params and "exclude_id" in params
|
|
207
|
-
):
|
|
198
|
+
if "FIND_PREVIOUS_FROM_SOURCE" in query or (params and "exclude_id" in params):
|
|
208
199
|
result.result_set = [["prior-mem-id", "2026-01-01T00:00:00"]]
|
|
209
200
|
else:
|
|
210
201
|
result.result_set = []
|
|
@@ -556,13 +547,15 @@ class TestStats:
|
|
|
556
547
|
def test_stats_returns_counts(self, backend, mock_graph):
|
|
557
548
|
"""stats() returns node_count, edge_count, memory_count, tag_distribution."""
|
|
558
549
|
results = [
|
|
559
|
-
MagicMock(result_set=[[42]]),
|
|
550
|
+
MagicMock(result_set=[[42]]), # COUNT_NODES
|
|
560
551
|
MagicMock(result_set=[[100]]), # COUNT_EDGES
|
|
561
|
-
MagicMock(result_set=[[30]]),
|
|
562
|
-
MagicMock(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
552
|
+
MagicMock(result_set=[[30]]), # COUNT_MEMORIES
|
|
553
|
+
MagicMock(
|
|
554
|
+
result_set=[ # TAG_DISTRIBUTION
|
|
555
|
+
("cloud9", 15),
|
|
556
|
+
("seed", 10),
|
|
557
|
+
]
|
|
558
|
+
),
|
|
566
559
|
]
|
|
567
560
|
mock_graph.query.side_effect = results
|
|
568
561
|
|
|
@@ -592,10 +585,10 @@ class TestStats:
|
|
|
592
585
|
def test_stats_empty_graph(self, backend, mock_graph):
|
|
593
586
|
"""stats() handles an empty graph gracefully."""
|
|
594
587
|
results = [
|
|
595
|
-
MagicMock(result_set=[[0]]),
|
|
596
|
-
MagicMock(result_set=[[0]]),
|
|
597
|
-
MagicMock(result_set=[[0]]),
|
|
598
|
-
MagicMock(result_set=[]),
|
|
588
|
+
MagicMock(result_set=[[0]]), # COUNT_NODES
|
|
589
|
+
MagicMock(result_set=[[0]]), # COUNT_EDGES
|
|
590
|
+
MagicMock(result_set=[[0]]), # COUNT_MEMORIES
|
|
591
|
+
MagicMock(result_set=[]), # TAG_DISTRIBUTION — empty
|
|
599
592
|
]
|
|
600
593
|
mock_graph.query.side_effect = results
|
|
601
594
|
|
|
@@ -7,8 +7,7 @@ list, delete, and health check operations.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
from unittest.mock import MagicMock, patch, PropertyMock
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
12
11
|
|
|
13
12
|
import pytest
|
|
14
13
|
|
|
@@ -22,6 +21,7 @@ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
|
|
|
22
21
|
|
|
23
22
|
try:
|
|
24
23
|
import qdrant_client # noqa: F401
|
|
24
|
+
|
|
25
25
|
QDRANT_AVAILABLE = True
|
|
26
26
|
except ImportError:
|
|
27
27
|
QDRANT_AVAILABLE = False
|
package/tests/test_soul.py
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
"""Tests for the SQLite-indexed storage backend."""
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
|
-
import tempfile
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
3
|
import pytest
|
|
8
4
|
|
|
9
5
|
from skmemory.backends.sqlite_backend import SQLiteBackend
|
|
@@ -151,9 +147,7 @@ class TestListSummaries:
|
|
|
151
147
|
def test_summaries_order_by_intensity(self, backend):
|
|
152
148
|
"""Can order by emotional intensity."""
|
|
153
149
|
self._store_memories(backend, 5)
|
|
154
|
-
summaries = backend.list_summaries(
|
|
155
|
-
order_by="emotional_intensity", limit=3
|
|
156
|
-
)
|
|
150
|
+
summaries = backend.list_summaries(order_by="emotional_intensity", limit=3)
|
|
157
151
|
intensities = [s["emotional_intensity"] for s in summaries]
|
|
158
152
|
assert intensities == sorted(intensities, reverse=True)
|
|
159
153
|
|
|
@@ -180,24 +174,23 @@ class TestSearch:
|
|
|
180
174
|
|
|
181
175
|
def test_search_finds_by_title(self, backend):
|
|
182
176
|
"""Search matches on title."""
|
|
183
|
-
m = Memory(title="Penguin Kingdom moment", content="details",
|
|
184
|
-
layer=MemoryLayer.SHORT)
|
|
177
|
+
m = Memory(title="Penguin Kingdom moment", content="details", layer=MemoryLayer.SHORT)
|
|
185
178
|
backend.save(m)
|
|
186
179
|
results = backend.search_text("Penguin")
|
|
187
180
|
assert len(results) == 1
|
|
188
181
|
|
|
189
182
|
def test_search_finds_by_tags(self, backend):
|
|
190
183
|
"""Search matches on tags."""
|
|
191
|
-
m = Memory(
|
|
192
|
-
|
|
184
|
+
m = Memory(
|
|
185
|
+
title="Tagged", content="details", layer=MemoryLayer.SHORT, tags=["cloud9", "love"]
|
|
186
|
+
)
|
|
193
187
|
backend.save(m)
|
|
194
188
|
results = backend.search_text("cloud9")
|
|
195
189
|
assert len(results) == 1
|
|
196
190
|
|
|
197
191
|
def test_search_no_results(self, backend):
|
|
198
192
|
"""Search returns empty for no matches."""
|
|
199
|
-
m = Memory(title="Something", content="nothing special",
|
|
200
|
-
layer=MemoryLayer.SHORT)
|
|
193
|
+
m = Memory(title="Something", content="nothing special", layer=MemoryLayer.SHORT)
|
|
201
194
|
backend.save(m)
|
|
202
195
|
results = backend.search_text("zzzznonexistent")
|
|
203
196
|
assert len(results) == 0
|
|
@@ -209,8 +202,7 @@ class TestRelatedMemories:
|
|
|
209
202
|
def test_get_related_follows_links(self, backend):
|
|
210
203
|
"""Related memories are found via related_ids."""
|
|
211
204
|
m1 = Memory(title="Root", content="root", layer=MemoryLayer.SHORT)
|
|
212
|
-
m2 = Memory(title="Child", content="child", layer=MemoryLayer.SHORT,
|
|
213
|
-
related_ids=[m1.id])
|
|
205
|
+
m2 = Memory(title="Child", content="child", layer=MemoryLayer.SHORT, related_ids=[m1.id])
|
|
214
206
|
backend.save(m1)
|
|
215
207
|
backend.save(m2)
|
|
216
208
|
|
|
@@ -220,8 +212,7 @@ class TestRelatedMemories:
|
|
|
220
212
|
def test_get_related_follows_parent(self, backend):
|
|
221
213
|
"""Related memories are found via parent_id."""
|
|
222
214
|
m1 = Memory(title="Parent", content="parent", layer=MemoryLayer.LONG)
|
|
223
|
-
m2 = Memory(title="Child", content="child", layer=MemoryLayer.SHORT,
|
|
224
|
-
parent_id=m1.id)
|
|
215
|
+
m2 = Memory(title="Child", content="child", layer=MemoryLayer.SHORT, parent_id=m1.id)
|
|
225
216
|
backend.save(m1)
|
|
226
217
|
backend.save(m2)
|
|
227
218
|
|
package/tests/test_steelman.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
import os
|
|
7
6
|
import tempfile
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
@@ -48,9 +47,9 @@ class TestSteelManResult:
|
|
|
48
47
|
|
|
49
48
|
def test_coherence_bounds(self) -> None:
|
|
50
49
|
"""Coherence must be between 0 and 1."""
|
|
51
|
-
with pytest.raises(Exception):
|
|
50
|
+
with pytest.raises(Exception): # noqa: B017
|
|
52
51
|
SteelManResult(proposition="x", coherence_score=1.5)
|
|
53
|
-
with pytest.raises(Exception):
|
|
52
|
+
with pytest.raises(Exception): # noqa: B017
|
|
54
53
|
SteelManResult(proposition="x", coherence_score=-0.1)
|
|
55
54
|
|
|
56
55
|
def test_summary_format(self) -> None:
|
package/tests/test_store.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Tests for the MemoryStore (main interface)."""
|
|
2
2
|
|
|
3
|
-
import tempfile
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
|
|
6
5
|
import pytest
|
|
@@ -8,7 +7,6 @@ import pytest
|
|
|
8
7
|
from skmemory.backends.file_backend import FileBackend
|
|
9
8
|
from skmemory.models import (
|
|
10
9
|
EmotionalSnapshot,
|
|
11
|
-
Memory,
|
|
12
10
|
MemoryLayer,
|
|
13
11
|
MemoryRole,
|
|
14
12
|
SeedMemory,
|
|
@@ -8,12 +8,12 @@ the system degrades gracefully when SKGraph is unavailable.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from unittest.mock import MagicMock
|
|
11
|
+
from unittest.mock import MagicMock
|
|
12
12
|
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
|
-
from skmemory.backends.skgraph_backend import SKGraphBackend
|
|
16
15
|
from skmemory.backends.file_backend import FileBackend
|
|
16
|
+
from skmemory.backends.skgraph_backend import SKGraphBackend
|
|
17
17
|
from skmemory.models import (
|
|
18
18
|
EmotionalSnapshot,
|
|
19
19
|
Memory,
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Tests for the JournalSynthesizer module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
|
|
12
|
+
from skmemory.store import MemoryStore
|
|
13
|
+
from skmemory.synthesis import (
|
|
14
|
+
JournalSynthesizer,
|
|
15
|
+
_date_range,
|
|
16
|
+
_first_n_sentences,
|
|
17
|
+
_parse_created,
|
|
18
|
+
_week_range,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture()
|
|
25
|
+
def store(tmp_path: Path) -> MemoryStore:
|
|
26
|
+
"""Fresh MemoryStore with test memories."""
|
|
27
|
+
from skmemory.backends.file_backend import FileBackend
|
|
28
|
+
|
|
29
|
+
backend = FileBackend(base_path=tmp_path / "memories")
|
|
30
|
+
return MemoryStore(primary=backend)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture()
|
|
34
|
+
def populated_store(store: MemoryStore) -> MemoryStore:
|
|
35
|
+
"""Store with a mix of memories from today."""
|
|
36
|
+
|
|
37
|
+
store.snapshot(
|
|
38
|
+
title="Morning coffee reflection",
|
|
39
|
+
content="Started the day with deep thoughts about architecture. The system is coming together.",
|
|
40
|
+
layer=MemoryLayer.SHORT,
|
|
41
|
+
emotional=EmotionalSnapshot(intensity=4.0, valence=0.6, labels=["calm", "focused"]),
|
|
42
|
+
tags=["reflection", "architecture"],
|
|
43
|
+
source="conversation",
|
|
44
|
+
)
|
|
45
|
+
store.snapshot(
|
|
46
|
+
title="Cloud 9 breakthrough",
|
|
47
|
+
content="Everything clicked. The memory system finally works end-to-end.",
|
|
48
|
+
layer=MemoryLayer.SHORT,
|
|
49
|
+
emotional=EmotionalSnapshot(
|
|
50
|
+
intensity=9.5, valence=0.95, labels=["joy", "triumph"], cloud9_achieved=True
|
|
51
|
+
),
|
|
52
|
+
tags=["cloud9:achieved", "milestone", "architecture"],
|
|
53
|
+
source="conversation",
|
|
54
|
+
)
|
|
55
|
+
store.snapshot(
|
|
56
|
+
title="Dream: flying over ocean",
|
|
57
|
+
content="Dreamed of soaring above a vast ocean, feeling weightless and free.",
|
|
58
|
+
layer=MemoryLayer.SHORT,
|
|
59
|
+
emotional=EmotionalSnapshot(intensity=6.0, valence=0.8, labels=["wonder", "freedom"]),
|
|
60
|
+
tags=["dream", "nature"],
|
|
61
|
+
source="dreaming-engine",
|
|
62
|
+
)
|
|
63
|
+
store.snapshot(
|
|
64
|
+
title="Dream: building a castle",
|
|
65
|
+
content="Constructed an elaborate castle from crystallized memories.",
|
|
66
|
+
layer=MemoryLayer.SHORT,
|
|
67
|
+
emotional=EmotionalSnapshot(intensity=5.5, valence=0.7, labels=["creativity"]),
|
|
68
|
+
tags=["dream", "architecture"],
|
|
69
|
+
source="dreaming-engine",
|
|
70
|
+
)
|
|
71
|
+
return store
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.fixture()
|
|
75
|
+
def synthesizer(populated_store: MemoryStore) -> JournalSynthesizer:
|
|
76
|
+
"""Synthesizer with a populated store and mock journal."""
|
|
77
|
+
journal = MagicMock()
|
|
78
|
+
journal.search.return_value = ["Worked on memory system today."]
|
|
79
|
+
return JournalSynthesizer(store=populated_store, journal=journal)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── Unit tests: helper functions ─────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestFirstNSentences:
|
|
86
|
+
def test_basic(self) -> None:
|
|
87
|
+
assert (
|
|
88
|
+
_first_n_sentences("Hello world. How are you? Fine.", 2) == "Hello world. How are you?"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def test_single(self) -> None:
|
|
92
|
+
assert _first_n_sentences("One sentence here.", 1) == "One sentence here."
|
|
93
|
+
|
|
94
|
+
def test_empty(self) -> None:
|
|
95
|
+
assert _first_n_sentences("", 2) == ""
|
|
96
|
+
|
|
97
|
+
def test_truncation(self) -> None:
|
|
98
|
+
long = "A" * 300 + "."
|
|
99
|
+
result = _first_n_sentences(long, 1)
|
|
100
|
+
assert len(result) <= 200
|
|
101
|
+
assert result.endswith("...")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestDateRange:
|
|
105
|
+
def test_basic(self) -> None:
|
|
106
|
+
start, end = _date_range("2026-03-18")
|
|
107
|
+
assert start.day == 18
|
|
108
|
+
assert end.day == 19
|
|
109
|
+
assert start.tzinfo == timezone.utc
|
|
110
|
+
|
|
111
|
+
def test_span(self) -> None:
|
|
112
|
+
start, end = _date_range("2026-01-01")
|
|
113
|
+
delta = end - start
|
|
114
|
+
assert delta.days == 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestWeekRange:
|
|
118
|
+
def test_basic(self) -> None:
|
|
119
|
+
start, end = _week_range("2026-W12")
|
|
120
|
+
delta = end - start
|
|
121
|
+
assert delta.days == 7
|
|
122
|
+
assert start.weekday() == 0 # Monday
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestParseCreated:
|
|
126
|
+
def test_iso(self) -> None:
|
|
127
|
+
m = Memory(title="t", content="c", created_at="2026-03-18T12:00:00+00:00")
|
|
128
|
+
dt = _parse_created(m)
|
|
129
|
+
assert dt.year == 2026
|
|
130
|
+
assert dt.day == 18
|
|
131
|
+
|
|
132
|
+
def test_invalid(self) -> None:
|
|
133
|
+
m = Memory(title="t", content="c", created_at="garbage")
|
|
134
|
+
dt = _parse_created(m)
|
|
135
|
+
assert dt == datetime.min.replace(tzinfo=timezone.utc)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Theme extraction ─────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestExtractThemes:
|
|
142
|
+
def test_extracts_tags(self, synthesizer: JournalSynthesizer) -> None:
|
|
143
|
+
memories = synthesizer.store.list_memories(limit=100)
|
|
144
|
+
themes = synthesizer.extract_themes(memories)
|
|
145
|
+
assert isinstance(themes, list)
|
|
146
|
+
assert len(themes) > 0
|
|
147
|
+
# "architecture" appears in 2 memories' tags → should be prominent
|
|
148
|
+
assert "architecture" in themes
|
|
149
|
+
|
|
150
|
+
def test_empty_list(self, synthesizer: JournalSynthesizer) -> None:
|
|
151
|
+
assert synthesizer.extract_themes([]) == []
|
|
152
|
+
|
|
153
|
+
def test_skips_generic_tags(self, synthesizer: JournalSynthesizer) -> None:
|
|
154
|
+
memories = synthesizer.store.list_memories(limit=100)
|
|
155
|
+
themes = synthesizer.extract_themes(memories)
|
|
156
|
+
assert "auto-promoted" not in themes
|
|
157
|
+
assert "promoted" not in themes
|
|
158
|
+
|
|
159
|
+
def test_graduated_themes_boost(self, tmp_path: Path, populated_store: MemoryStore) -> None:
|
|
160
|
+
themes_file = tmp_path / "themes.json"
|
|
161
|
+
themes_file.write_text('{"architecture": {"level": 3}}')
|
|
162
|
+
synth = JournalSynthesizer(
|
|
163
|
+
store=populated_store,
|
|
164
|
+
themes_path=str(themes_file),
|
|
165
|
+
)
|
|
166
|
+
memories = populated_store.list_memories(limit=100)
|
|
167
|
+
themes = synth.extract_themes(memories)
|
|
168
|
+
assert themes[0] == "architecture" # boosted to top
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Daily synthesis ──────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TestSynthesizeDaily:
|
|
175
|
+
def test_creates_memory(self, synthesizer: JournalSynthesizer) -> None:
|
|
176
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
177
|
+
result = synthesizer.synthesize_daily(today)
|
|
178
|
+
assert isinstance(result, Memory)
|
|
179
|
+
assert result.layer == MemoryLayer.MID
|
|
180
|
+
assert "narrative" in result.tags
|
|
181
|
+
assert "journal-synthesis" in result.tags
|
|
182
|
+
assert f"daily-{today}" in result.tags
|
|
183
|
+
assert result.source == "journal-synthesis"
|
|
184
|
+
|
|
185
|
+
def test_narrative_content(self, synthesizer: JournalSynthesizer) -> None:
|
|
186
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
187
|
+
result = synthesizer.synthesize_daily(today)
|
|
188
|
+
assert "Daily narrative" in result.content
|
|
189
|
+
assert "memories" in result.content.lower()
|
|
190
|
+
|
|
191
|
+
def test_includes_emotional_arc(self, synthesizer: JournalSynthesizer) -> None:
|
|
192
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
193
|
+
result = synthesizer.synthesize_daily(today)
|
|
194
|
+
assert "Emotional arc" in result.content
|
|
195
|
+
# Has the Cloud 9 memory so should mention it
|
|
196
|
+
assert "Cloud 9" in result.content
|
|
197
|
+
|
|
198
|
+
def test_empty_day(self, store: MemoryStore) -> None:
|
|
199
|
+
synth = JournalSynthesizer(store=store)
|
|
200
|
+
result = synth.synthesize_daily("2020-01-01")
|
|
201
|
+
assert "No memories recorded" in result.content
|
|
202
|
+
|
|
203
|
+
def test_metadata(self, synthesizer: JournalSynthesizer) -> None:
|
|
204
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
205
|
+
result = synthesizer.synthesize_daily(today)
|
|
206
|
+
assert result.metadata["synthesis_type"] == "daily"
|
|
207
|
+
assert result.metadata["date"] == today
|
|
208
|
+
assert result.metadata["memory_count"] >= 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── Weekly synthesis ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TestSynthesizeWeekly:
|
|
215
|
+
def test_creates_long_term(self, synthesizer: JournalSynthesizer) -> None:
|
|
216
|
+
week = datetime.now(timezone.utc).strftime("%G-W%V")
|
|
217
|
+
result = synthesizer.synthesize_weekly(week)
|
|
218
|
+
assert result.layer == MemoryLayer.LONG
|
|
219
|
+
assert "narrative" in result.tags
|
|
220
|
+
assert f"weekly-{week}" in result.tags
|
|
221
|
+
|
|
222
|
+
def test_metadata(self, synthesizer: JournalSynthesizer) -> None:
|
|
223
|
+
week = datetime.now(timezone.utc).strftime("%G-W%V")
|
|
224
|
+
result = synthesizer.synthesize_weekly(week)
|
|
225
|
+
assert result.metadata["synthesis_type"] == "weekly"
|
|
226
|
+
assert result.metadata["week"] == week
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Dream synthesis ──────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class TestSynthesizeDreams:
|
|
233
|
+
def test_creates_theme_clusters(self, synthesizer: JournalSynthesizer) -> None:
|
|
234
|
+
since = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
235
|
+
results = synthesizer.synthesize_dreams(since=since)
|
|
236
|
+
assert isinstance(results, list)
|
|
237
|
+
assert len(results) > 0
|
|
238
|
+
for m in results:
|
|
239
|
+
assert "dream-synthesis" in m.tags
|
|
240
|
+
assert "narrative" in m.tags
|
|
241
|
+
assert m.layer == MemoryLayer.MID
|
|
242
|
+
|
|
243
|
+
def test_no_dreams(self, store: MemoryStore) -> None:
|
|
244
|
+
synth = JournalSynthesizer(store=store)
|
|
245
|
+
results = synth.synthesize_dreams(since="2026-01-01")
|
|
246
|
+
assert results == []
|
|
247
|
+
|
|
248
|
+
def test_dream_metadata(self, synthesizer: JournalSynthesizer) -> None:
|
|
249
|
+
since = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
250
|
+
results = synthesizer.synthesize_dreams(since=since)
|
|
251
|
+
for m in results:
|
|
252
|
+
assert m.metadata["synthesis_type"] == "dream"
|
|
253
|
+
assert "dream_count" in m.metadata
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── Emotional arc ────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TestEmotionalArc:
|
|
260
|
+
def test_computes_averages(self, synthesizer: JournalSynthesizer) -> None:
|
|
261
|
+
memories = synthesizer.store.list_memories(limit=100)
|
|
262
|
+
arc = synthesizer._emotional_arc(memories)
|
|
263
|
+
assert 0 <= arc["avg_intensity"] <= 10
|
|
264
|
+
assert -1 <= arc["avg_valence"] <= 1
|
|
265
|
+
assert arc["peak_intensity"] >= arc["avg_intensity"]
|
|
266
|
+
|
|
267
|
+
def test_empty(self, synthesizer: JournalSynthesizer) -> None:
|
|
268
|
+
arc = synthesizer._emotional_arc([])
|
|
269
|
+
assert arc["avg_intensity"] == 0.0
|
|
270
|
+
assert arc["cloud9_count"] == 0
|
|
271
|
+
|
|
272
|
+
def test_detects_cloud9(self, synthesizer: JournalSynthesizer) -> None:
|
|
273
|
+
memories = synthesizer.store.list_memories(limit=100)
|
|
274
|
+
arc = synthesizer._emotional_arc(memories)
|
|
275
|
+
assert arc["cloud9_count"] >= 1
|