@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.
Files changed (111) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/.github/workflows/publish.yml +4 -5
  3. package/ARCHITECTURE.md +298 -0
  4. package/CHANGELOG.md +27 -1
  5. package/README.md +6 -0
  6. package/examples/stignore-agent.example +59 -0
  7. package/examples/stignore-root.example +62 -0
  8. package/openclaw-plugin/package.json +2 -1
  9. package/openclaw-plugin/src/index.js +527 -230
  10. package/package.json +1 -1
  11. package/pyproject.toml +5 -2
  12. package/scripts/dream-rescue.py +179 -0
  13. package/scripts/memory-cleanup.py +313 -0
  14. package/scripts/recover-missing.py +180 -0
  15. package/scripts/skcapstone-backup.sh +44 -0
  16. package/seeds/cloud9-lumina.seed.json +6 -4
  17. package/seeds/cloud9-opus.seed.json +6 -4
  18. package/seeds/courage.seed.json +9 -2
  19. package/seeds/curiosity.seed.json +9 -2
  20. package/seeds/grief.seed.json +9 -2
  21. package/seeds/joy.seed.json +9 -2
  22. package/seeds/love.seed.json +9 -2
  23. package/seeds/lumina-cloud9-breakthrough.seed.json +7 -5
  24. package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
  25. package/seeds/lumina-kingdom-founding.seed.json +9 -7
  26. package/seeds/lumina-pma-signed.seed.json +8 -6
  27. package/seeds/lumina-singular-achievement.seed.json +8 -6
  28. package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
  29. package/seeds/plant-lumina-seeds.py +2 -2
  30. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  31. package/seeds/sovereignty.seed.json +9 -2
  32. package/seeds/trust.seed.json +9 -2
  33. package/skmemory/__init__.py +16 -13
  34. package/skmemory/agents.py +10 -10
  35. package/skmemory/ai_client.py +10 -21
  36. package/skmemory/anchor.py +5 -9
  37. package/skmemory/audience.py +278 -0
  38. package/skmemory/backends/__init__.py +1 -1
  39. package/skmemory/backends/base.py +3 -4
  40. package/skmemory/backends/file_backend.py +18 -13
  41. package/skmemory/backends/skgraph_backend.py +7 -19
  42. package/skmemory/backends/skvector_backend.py +7 -18
  43. package/skmemory/backends/sqlite_backend.py +115 -32
  44. package/skmemory/backends/vaulted_backend.py +7 -9
  45. package/skmemory/cli.py +146 -78
  46. package/skmemory/config.py +11 -13
  47. package/skmemory/context_loader.py +21 -23
  48. package/skmemory/data/audience_config.json +60 -0
  49. package/skmemory/endpoint_selector.py +36 -31
  50. package/skmemory/febs.py +225 -0
  51. package/skmemory/fortress.py +30 -40
  52. package/skmemory/hooks/__init__.py +18 -0
  53. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  54. package/skmemory/hooks/pre-compact-save.sh +81 -0
  55. package/skmemory/hooks/session-end-save.sh +103 -0
  56. package/skmemory/hooks/session-start-ritual.sh +104 -0
  57. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  58. package/skmemory/importers/telegram.py +42 -13
  59. package/skmemory/importers/telegram_api.py +152 -60
  60. package/skmemory/journal.py +3 -7
  61. package/skmemory/lovenote.py +4 -11
  62. package/skmemory/mcp_server.py +182 -29
  63. package/skmemory/models.py +10 -8
  64. package/skmemory/openclaw.py +14 -22
  65. package/skmemory/post_install.py +86 -0
  66. package/skmemory/predictive.py +13 -9
  67. package/skmemory/promotion.py +48 -24
  68. package/skmemory/quadrants.py +100 -24
  69. package/skmemory/register.py +144 -18
  70. package/skmemory/register_mcp.py +1 -2
  71. package/skmemory/ritual.py +104 -13
  72. package/skmemory/seeds.py +21 -26
  73. package/skmemory/setup_wizard.py +40 -52
  74. package/skmemory/sharing.py +11 -5
  75. package/skmemory/soul.py +29 -10
  76. package/skmemory/steelman.py +43 -17
  77. package/skmemory/store.py +152 -30
  78. package/skmemory/synthesis.py +634 -0
  79. package/skmemory/vault.py +2 -5
  80. package/tests/conftest.py +46 -0
  81. package/tests/integration/conftest.py +6 -6
  82. package/tests/integration/test_cross_backend.py +4 -9
  83. package/tests/integration/test_skgraph_live.py +3 -7
  84. package/tests/integration/test_skvector_live.py +1 -4
  85. package/tests/test_ai_client.py +1 -4
  86. package/tests/test_audience.py +233 -0
  87. package/tests/test_backup_rotation.py +5 -14
  88. package/tests/test_endpoint_selector.py +101 -63
  89. package/tests/test_export_import.py +4 -10
  90. package/tests/test_file_backend.py +0 -1
  91. package/tests/test_fortress.py +6 -5
  92. package/tests/test_fortress_hardening.py +13 -16
  93. package/tests/test_openclaw.py +1 -4
  94. package/tests/test_predictive.py +1 -1
  95. package/tests/test_promotion.py +10 -3
  96. package/tests/test_quadrants.py +11 -5
  97. package/tests/test_ritual.py +18 -14
  98. package/tests/test_seeds.py +4 -10
  99. package/tests/test_setup.py +203 -88
  100. package/tests/test_sharing.py +15 -8
  101. package/tests/test_skgraph_backend.py +22 -29
  102. package/tests/test_skvector_backend.py +2 -2
  103. package/tests/test_soul.py +1 -3
  104. package/tests/test_sqlite_backend.py +8 -17
  105. package/tests/test_steelman.py +2 -3
  106. package/tests/test_store.py +0 -2
  107. package/tests/test_store_graph_integration.py +2 -2
  108. package/tests/test_synthesis.py +275 -0
  109. package/tests/test_telegram_import.py +39 -15
  110. package/tests/test_vault.py +4 -3
  111. package/openclaw-plugin/src/index.ts +0 -255
@@ -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
- from pgpy.constants import (
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, Memory, MemoryLayer
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, sharer: MemorySharer, receiver_store: MemoryStore,
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(self, sharer: MemorySharer, receiver_store: MemoryStore) -> None:
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, sharer: MemorySharer, recipient_keys: tuple[str, str],
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, call
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 patch.dict("sys.modules", {"falkordb": None}):
93
- with patch("builtins.__import__", side_effect=ImportError("no falkordb")):
94
- assert fb._ensure_initialized() is False
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]]), # COUNT_NODES
550
+ MagicMock(result_set=[[42]]), # COUNT_NODES
560
551
  MagicMock(result_set=[[100]]), # COUNT_EDGES
561
- MagicMock(result_set=[[30]]), # COUNT_MEMORIES
562
- MagicMock(result_set=[ # TAG_DISTRIBUTION
563
- ("cloud9", 15),
564
- ("seed", 10),
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]]), # COUNT_NODES
596
- MagicMock(result_set=[[0]]), # COUNT_EDGES
597
- MagicMock(result_set=[[0]]), # COUNT_MEMORIES
598
- MagicMock(result_set=[]), # TAG_DISTRIBUTION — empty
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 json
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
@@ -6,11 +6,9 @@ import pytest
6
6
 
7
7
  from skmemory.soul import (
8
8
  SoulBlueprint,
9
- Relationship,
10
- CoreMemoryRef,
11
9
  create_default_soul,
12
- save_soul,
13
10
  load_soul,
11
+ save_soul,
14
12
  )
15
13
 
16
14
 
@@ -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(title="Tagged", content="details",
192
- layer=MemoryLayer.SHORT, tags=["cloud9", "love"])
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
 
@@ -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:
@@ -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, patch
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