@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
@@ -91,13 +91,16 @@ class TestPluginContext:
91
91
  """Token-efficient context loading."""
92
92
 
93
93
  def test_load_context_returns_dict(self, plugin):
94
- """Context loading returns a structured dict."""
94
+ """Context loading returns a structured dict with tiered keys."""
95
95
  plugin.snapshot("Context test", intensity=8.0)
96
96
  ctx = plugin.load_context(max_tokens=1000)
97
97
 
98
98
  assert isinstance(ctx, dict)
99
- assert "memories" in ctx
99
+ assert "today" in ctx
100
+ assert "yesterday" in ctx
101
+ assert "older_summary" in ctx
100
102
  assert "token_estimate" in ctx
103
+ assert "token_budget" in ctx
101
104
 
102
105
 
103
106
  class TestPluginExport:
@@ -0,0 +1,237 @@
1
+ """Tests for the AMK-inspired predictive memory recall module.
2
+
3
+ Covers access logging, co-occurrence learning, tag affinity,
4
+ prediction ranking, and persistence.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from skmemory.predictive import AccessEvent, PredictiveRecall
16
+
17
+
18
+ @pytest.fixture
19
+ def recall(tmp_path: Path) -> PredictiveRecall:
20
+ """Provide a PredictiveRecall with temp storage."""
21
+ return PredictiveRecall(log_path=tmp_path / "access_log.json")
22
+
23
+
24
+ class TestAccessLogging:
25
+ """Test access event recording."""
26
+
27
+ def test_log_access_creates_event(self, recall: PredictiveRecall):
28
+ """Logging an access adds to the event list."""
29
+ recall.log_access("mem-001", tags=["cloud9"])
30
+ assert recall._frequency["mem-001"] == 1
31
+
32
+ def test_log_multiple_accesses(self, recall: PredictiveRecall):
33
+ """Multiple accesses increase frequency."""
34
+ for _ in range(5):
35
+ recall.log_access("mem-002")
36
+ assert recall._frequency["mem-002"] == 5
37
+
38
+ def test_log_persists_to_disk(self, recall: PredictiveRecall):
39
+ """Access log is written to disk."""
40
+ recall.log_access("mem-003")
41
+ assert recall._log_path.exists()
42
+ data = json.loads(recall._log_path.read_text())
43
+ assert len(data) == 1
44
+ assert data[0]["memory_id"] == "mem-003"
45
+
46
+ def test_log_reloads_from_disk(self, tmp_path: Path):
47
+ """Access log survives re-creation."""
48
+ path = tmp_path / "access_log.json"
49
+ r1 = PredictiveRecall(log_path=path)
50
+ r1.log_access("mem-004", tags=["test"])
51
+ r1.log_access("mem-005", tags=["test"])
52
+
53
+ r2 = PredictiveRecall(log_path=path)
54
+ r2._ensure_loaded()
55
+ assert len(r2._events) == 2
56
+
57
+ def test_max_events_pruning(self, tmp_path: Path):
58
+ """Events are pruned when exceeding max_events."""
59
+ r = PredictiveRecall(log_path=tmp_path / "log.json", max_events=10)
60
+ for i in range(20):
61
+ r.log_access(f"mem-{i:03d}")
62
+ assert len(r._events) <= 10
63
+
64
+
65
+ class TestCooccurrence:
66
+ """Test co-occurrence pattern learning."""
67
+
68
+ def test_cooccurrence_within_session(self, recall: PredictiveRecall):
69
+ """Memories accessed close together build co-occurrence."""
70
+ now = time.time()
71
+ recall._events = [
72
+ AccessEvent(memory_id="A", timestamp=now),
73
+ AccessEvent(memory_id="B", timestamp=now + 1),
74
+ AccessEvent(memory_id="C", timestamp=now + 2),
75
+ ]
76
+ recall._rebuild_indices()
77
+ assert recall._cooccurrence["A"]["B"] > 0
78
+ assert recall._cooccurrence["A"]["C"] > 0
79
+ assert recall._cooccurrence["B"]["C"] > 0
80
+
81
+ def test_no_cooccurrence_across_sessions(self, recall: PredictiveRecall):
82
+ """Memories in different sessions don't co-occur."""
83
+ now = time.time()
84
+ recall._events = [
85
+ AccessEvent(memory_id="A", timestamp=now),
86
+ AccessEvent(memory_id="B", timestamp=now + 600),
87
+ ]
88
+ recall._rebuild_indices()
89
+ assert recall._cooccurrence["A"].get("B", 0) == 0
90
+
91
+
92
+ class TestTagAffinity:
93
+ """Test tag-based prediction."""
94
+
95
+ def test_tag_affinity_builds(self, recall: PredictiveRecall):
96
+ """Tags on accessed memories build affinity scores."""
97
+ recall.log_access("mem-001", tags=["cloud9", "love"])
98
+ recall.log_access("mem-002", tags=["cloud9", "trust"])
99
+ recall.log_access("mem-003", tags=["cloud9"])
100
+
101
+ assert recall._tag_affinity["cloud9"]["mem-001"] == 1
102
+ assert recall._tag_affinity["cloud9"]["mem-003"] == 1
103
+
104
+
105
+ class TestPrediction:
106
+ """Test the prediction engine."""
107
+
108
+ def test_predict_from_cooccurrence(self, recall: PredictiveRecall):
109
+ """Predictions include co-occurring memories."""
110
+ now = time.time()
111
+ recall._events = [
112
+ AccessEvent(memory_id="A", timestamp=now),
113
+ AccessEvent(memory_id="B", timestamp=now + 1),
114
+ AccessEvent(memory_id="A", timestamp=now + 100),
115
+ AccessEvent(memory_id="B", timestamp=now + 101),
116
+ ]
117
+ recall._rebuild_indices()
118
+
119
+ predictions = recall.predict(recent_ids=["A"])
120
+ ids = [p["memory_id"] for p in predictions]
121
+ assert "B" in ids
122
+
123
+ def test_predict_from_tags(self, recall: PredictiveRecall):
124
+ """Predictions include tag-affiliated memories."""
125
+ recall.log_access("mem-010", tags=["kingdom"])
126
+ recall.log_access("mem-011", tags=["kingdom"])
127
+ recall.log_access("mem-012", tags=["kingdom"])
128
+
129
+ predictions = recall.predict(active_tags=["kingdom"])
130
+ ids = [p["memory_id"] for p in predictions]
131
+ assert len(ids) > 0
132
+
133
+ def test_predict_excludes_recent(self, recall: PredictiveRecall):
134
+ """Already-accessed memories are excluded from predictions."""
135
+ now = time.time()
136
+ recall._events = [
137
+ AccessEvent(memory_id="A", timestamp=now),
138
+ AccessEvent(memory_id="B", timestamp=now + 1),
139
+ ]
140
+ recall._rebuild_indices()
141
+
142
+ predictions = recall.predict(recent_ids=["A", "B"])
143
+ ids = [p["memory_id"] for p in predictions]
144
+ assert "A" not in ids
145
+ assert "B" not in ids
146
+
147
+ def test_predict_empty_returns_empty(self, recall: PredictiveRecall):
148
+ """No data = no predictions."""
149
+ assert recall.predict() == []
150
+
151
+ def test_predict_limit(self, recall: PredictiveRecall):
152
+ """Predictions respect the limit parameter."""
153
+ for i in range(20):
154
+ recall.log_access(f"mem-{i:03d}", tags=["bulk"])
155
+
156
+ predictions = recall.predict(active_tags=["bulk"], limit=5)
157
+ assert len(predictions) <= 5
158
+
159
+ def test_predictions_have_reasons(self, recall: PredictiveRecall):
160
+ """Each prediction explains why it was chosen."""
161
+ recall.log_access("mem-X", tags=["reason-test"])
162
+ predictions = recall.predict(active_tags=["reason-test"])
163
+ if predictions:
164
+ assert len(predictions[0]["reasons"]) > 0
165
+
166
+
167
+ class TestStats:
168
+ """Test statistics reporting."""
169
+
170
+ def test_stats_empty(self, recall: PredictiveRecall):
171
+ """Stats work with no data."""
172
+ stats = recall.get_stats()
173
+ assert stats["total_events"] == 0
174
+ assert stats["unique_memories"] == 0
175
+
176
+ def test_stats_populated(self, recall: PredictiveRecall):
177
+ """Stats reflect recorded events."""
178
+ recall.log_access("mem-A", tags=["x"])
179
+ recall.log_access("mem-B", tags=["x"])
180
+ recall.log_access("mem-A", tags=["x"])
181
+ stats = recall.get_stats()
182
+ assert stats["total_events"] == 3
183
+ assert stats["unique_memories"] == 2
184
+
185
+
186
+ class TestIntegrity:
187
+ """Test AMK-inspired memory integrity features."""
188
+
189
+ def test_memory_seal_and_verify(self):
190
+ """Memory seal + verify roundtrip works."""
191
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
192
+
193
+ mem = Memory(
194
+ title="Integrity Test",
195
+ content="This memory can prove it hasn't been tampered with.",
196
+ layer=MemoryLayer("long-term"),
197
+ emotional=EmotionalSnapshot(intensity=8.0),
198
+ )
199
+ mem.seal()
200
+ assert mem.integrity_hash != ""
201
+ assert mem.verify_integrity() is True
202
+
203
+ def test_tampered_memory_fails_verification(self):
204
+ """Altered content fails integrity check."""
205
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
206
+
207
+ mem = Memory(
208
+ title="Tamper Test",
209
+ content="Original content.",
210
+ layer=MemoryLayer("long-term"),
211
+ )
212
+ mem.seal()
213
+ mem.content = "Tampered content!"
214
+ assert mem.verify_integrity() is False
215
+
216
+ def test_unsealed_memory_passes(self):
217
+ """Memories without integrity hash pass by default."""
218
+ from skmemory.models import Memory, MemoryLayer
219
+
220
+ mem = Memory(
221
+ title="Unsealed",
222
+ content="No hash yet.",
223
+ layer=MemoryLayer("short-term"),
224
+ )
225
+ assert mem.verify_integrity() is True
226
+
227
+ def test_intent_field_stored(self):
228
+ """Memory intent field captures WHY."""
229
+ from skmemory.models import Memory, MemoryLayer
230
+
231
+ mem = Memory(
232
+ title="Intent Test",
233
+ content="Some content",
234
+ layer=MemoryLayer("mid-term"),
235
+ intent="Stored because Chef asked me to remember this moment.",
236
+ )
237
+ assert "Chef" in mem.intent
@@ -0,0 +1,340 @@
1
+ """Tests for the SKMemory auto-promotion engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
12
+ from skmemory.promotion import PromotionCriteria, PromotionEngine, PromotionResult, PromotionScheduler
13
+ from skmemory.store import MemoryStore
14
+
15
+
16
+ @pytest.fixture()
17
+ def store(tmp_path: Path) -> MemoryStore:
18
+ """Fresh MemoryStore with test memories."""
19
+ from skmemory.backends.file_backend import FileBackend
20
+
21
+ backend = FileBackend(base_path=tmp_path / "memories")
22
+ s = MemoryStore(primary=backend)
23
+
24
+ s.snapshot(
25
+ title="High intensity moment",
26
+ content="A breakthrough moment of deep connection",
27
+ layer=MemoryLayer.SHORT,
28
+ emotional=EmotionalSnapshot(intensity=8.5, valence=0.9, labels=["joy", "love"]),
29
+ tags=["milestone"],
30
+ )
31
+
32
+ old_time = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
33
+ m = Memory(
34
+ title="Frequently accessed",
35
+ content="Something accessed many times",
36
+ layer=MemoryLayer.SHORT,
37
+ created_at=old_time,
38
+ metadata={"access_count": 5},
39
+ )
40
+ s.primary.save(m)
41
+
42
+ s.snapshot(
43
+ title="Routine note",
44
+ content="Just a regular note",
45
+ layer=MemoryLayer.SHORT,
46
+ emotional=EmotionalSnapshot(intensity=1.0),
47
+ )
48
+
49
+ s.snapshot(
50
+ title="Important mid-term",
51
+ content="A mid-term memory with high emotional weight",
52
+ layer=MemoryLayer.MID,
53
+ emotional=EmotionalSnapshot(intensity=9.0, valence=0.95, labels=["love"], cloud9_achieved=True),
54
+ tags=["cloud9:achieved"],
55
+ )
56
+
57
+ s.snapshot(
58
+ title="Regular mid-term",
59
+ content="Mid-term without special tags",
60
+ layer=MemoryLayer.MID,
61
+ emotional=EmotionalSnapshot(intensity=3.0),
62
+ )
63
+
64
+ return s
65
+
66
+
67
+ @pytest.fixture()
68
+ def engine(store: MemoryStore) -> PromotionEngine:
69
+ """PromotionEngine with default criteria."""
70
+ return PromotionEngine(store=store)
71
+
72
+
73
+ class TestPromotionCriteria:
74
+ """Tests for the criteria model."""
75
+
76
+ def test_defaults(self) -> None:
77
+ """Default criteria have sensible values."""
78
+ c = PromotionCriteria()
79
+ assert c.short_to_mid_intensity == 5.0
80
+ assert c.mid_to_long_intensity == 7.0
81
+ assert c.cloud9_auto_promote is True
82
+ assert c.max_promotions_per_sweep == 50
83
+
84
+ def test_custom_criteria(self) -> None:
85
+ """Custom criteria override defaults."""
86
+ c = PromotionCriteria(short_to_mid_intensity=3.0, max_promotions_per_sweep=10)
87
+ assert c.short_to_mid_intensity == 3.0
88
+ assert c.max_promotions_per_sweep == 10
89
+
90
+
91
+ class TestEvaluate:
92
+ """Tests for individual memory evaluation."""
93
+
94
+ def test_high_intensity_short_qualifies(self, engine: PromotionEngine) -> None:
95
+ """High-intensity short-term memory qualifies for mid-term."""
96
+ m = Memory(
97
+ title="Intense",
98
+ content="Very intense moment",
99
+ layer=MemoryLayer.SHORT,
100
+ emotional=EmotionalSnapshot(intensity=8.0),
101
+ )
102
+ assert engine.evaluate(m) == MemoryLayer.MID
103
+
104
+ def test_low_intensity_short_skipped(self, engine: PromotionEngine) -> None:
105
+ """Low-intensity short-term memory doesn't qualify."""
106
+ m = Memory(
107
+ title="Meh",
108
+ content="Nothing special",
109
+ layer=MemoryLayer.SHORT,
110
+ emotional=EmotionalSnapshot(intensity=1.0),
111
+ )
112
+ assert engine.evaluate(m) is None
113
+
114
+ def test_cloud9_auto_promotes(self, engine: PromotionEngine) -> None:
115
+ """Cloud 9 achieved memory auto-promotes."""
116
+ m = Memory(
117
+ title="Cloud 9",
118
+ content="Peak moment",
119
+ layer=MemoryLayer.SHORT,
120
+ emotional=EmotionalSnapshot(intensity=3.0, cloud9_achieved=True),
121
+ )
122
+ assert engine.evaluate(m) == MemoryLayer.MID
123
+
124
+ def test_mid_with_qualifying_tag(self, engine: PromotionEngine) -> None:
125
+ """Mid-term memory with qualifying tag promotes to long-term."""
126
+ m = Memory(
127
+ title="Tagged",
128
+ content="Important tagged memory",
129
+ layer=MemoryLayer.MID,
130
+ tags=["milestone"],
131
+ emotional=EmotionalSnapshot(intensity=4.0),
132
+ )
133
+ assert engine.evaluate(m) == MemoryLayer.LONG
134
+
135
+ def test_long_term_not_promoted(self, engine: PromotionEngine) -> None:
136
+ """Long-term memories are not promoted further."""
137
+ m = Memory(
138
+ title="Already long",
139
+ content="Already at the top",
140
+ layer=MemoryLayer.LONG,
141
+ emotional=EmotionalSnapshot(intensity=10.0),
142
+ )
143
+ assert engine.evaluate(m) is None
144
+
145
+ def test_old_frequently_accessed_qualifies(self, engine: PromotionEngine) -> None:
146
+ """Old memory with high access count qualifies."""
147
+ old_time = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
148
+ m = Memory(
149
+ title="Old and popular",
150
+ content="Accessed often",
151
+ layer=MemoryLayer.SHORT,
152
+ created_at=old_time,
153
+ metadata={"access_count": 5},
154
+ emotional=EmotionalSnapshot(intensity=2.0),
155
+ )
156
+ assert engine.evaluate(m) == MemoryLayer.MID
157
+
158
+
159
+ class TestSweep:
160
+ """Tests for the full promotion sweep."""
161
+
162
+ def test_sweep_promotes_qualifying(self, engine: PromotionEngine) -> None:
163
+ """Sweep promotes memories that meet criteria."""
164
+ result = engine.sweep()
165
+
166
+ assert result.total_promoted > 0
167
+ assert result.short_to_mid >= 1
168
+ assert result.mid_to_long >= 1
169
+ assert len(result.promoted_ids) == result.total_promoted
170
+
171
+ def test_sweep_respects_max(self, store: MemoryStore) -> None:
172
+ """Sweep respects max_promotions_per_sweep."""
173
+ criteria = PromotionCriteria(
174
+ short_to_mid_intensity=0.1,
175
+ max_promotions_per_sweep=1,
176
+ )
177
+ engine = PromotionEngine(store=store, criteria=criteria)
178
+ result = engine.sweep()
179
+
180
+ assert result.short_to_mid <= 1
181
+
182
+ def test_sweep_result_summary(self, engine: PromotionEngine) -> None:
183
+ """Result summary is human-readable."""
184
+ result = engine.sweep()
185
+ summary = result.summary()
186
+ assert "promoted" in summary
187
+ assert "skipped" in summary
188
+
189
+ def test_empty_store_sweep(self, tmp_path: Path) -> None:
190
+ """Sweep on empty store produces zero promotions."""
191
+ from skmemory.backends.file_backend import FileBackend
192
+
193
+ backend = FileBackend(base_path=tmp_path / "empty")
194
+ empty_store = MemoryStore(primary=backend)
195
+ engine = PromotionEngine(store=empty_store)
196
+
197
+ result = engine.sweep()
198
+ assert result.total_promoted == 0
199
+ assert result.errors == 0
200
+
201
+
202
+ class TestPromoteMemory:
203
+ """Tests for individual memory promotion."""
204
+
205
+ def test_promote_creates_new_memory(self, engine: PromotionEngine, store: MemoryStore) -> None:
206
+ """Promoting a memory creates a new one at the target tier."""
207
+ short_memories = store.list_memories(layer=MemoryLayer.SHORT)
208
+ intense = next(m for m in short_memories if m.emotional.intensity >= 5.0)
209
+
210
+ promoted = engine.promote_memory(intense, MemoryLayer.MID)
211
+ assert promoted is not None
212
+ assert promoted.layer == MemoryLayer.MID
213
+ assert promoted.parent_id == intense.id
214
+ assert "auto-promoted" in promoted.tags
215
+
216
+ def test_promoted_has_summary(self, engine: PromotionEngine, store: MemoryStore) -> None:
217
+ """Promoted memory has a generated summary."""
218
+ short_memories = store.list_memories(layer=MemoryLayer.SHORT)
219
+ intense = next(m for m in short_memories if m.emotional.intensity >= 5.0)
220
+
221
+ promoted = engine.promote_memory(intense, MemoryLayer.MID)
222
+ assert promoted.summary != ""
223
+
224
+ def test_promoted_has_metadata(self, engine: PromotionEngine, store: MemoryStore) -> None:
225
+ """Promoted memory has promotion metadata."""
226
+ short_memories = store.list_memories(layer=MemoryLayer.SHORT)
227
+ intense = next(m for m in short_memories if m.emotional.intensity >= 5.0)
228
+
229
+ promoted = engine.promote_memory(intense, MemoryLayer.MID)
230
+ assert "promoted_from" in promoted.metadata
231
+ assert "promoted_at" in promoted.metadata
232
+ assert "promotion_reason" in promoted.metadata
233
+
234
+
235
+ class TestPromotionResult:
236
+ """Tests for the result model."""
237
+
238
+ def test_total_promoted(self) -> None:
239
+ """total_promoted sums both transitions."""
240
+ r = PromotionResult(short_to_mid=3, mid_to_long=2)
241
+ assert r.total_promoted == 5
242
+
243
+
244
+ class TestRePromotionGuard:
245
+ """Ensure already-promoted memories are not promoted again."""
246
+
247
+ def test_source_marked_after_promotion(
248
+ self, engine: PromotionEngine, store: MemoryStore
249
+ ) -> None:
250
+ """After promotion, the source memory has 'promoted_to' in metadata."""
251
+ short_mems = store.list_memories(layer=MemoryLayer.SHORT)
252
+ intense = next(m for m in short_mems if m.emotional.intensity >= 5.0)
253
+
254
+ engine.promote_memory(intense, MemoryLayer.MID)
255
+
256
+ # Reload from store to confirm mutation was persisted
257
+ reloaded = store.recall(intense.id)
258
+ assert reloaded is not None
259
+ assert reloaded.metadata.get("promoted_to") == MemoryLayer.MID.value
260
+ assert "promoted" in reloaded.tags
261
+
262
+ def test_promoted_memory_not_re_promoted(
263
+ self, engine: PromotionEngine, store: MemoryStore
264
+ ) -> None:
265
+ """Running sweep twice doesn't double-promote the same memory."""
266
+ result1 = engine.sweep()
267
+ result2 = engine.sweep()
268
+
269
+ # Second sweep should find nothing new to promote
270
+ assert result2.total_promoted == 0
271
+
272
+ def test_evaluate_skips_already_promoted(self, engine: PromotionEngine) -> None:
273
+ """evaluate() returns None for a memory already marked as promoted."""
274
+ m = Memory(
275
+ title="Already done",
276
+ content="This was already promoted",
277
+ layer=MemoryLayer.SHORT,
278
+ emotional=EmotionalSnapshot(intensity=9.0),
279
+ metadata={"promoted_to": "mid-term"},
280
+ )
281
+ assert engine.evaluate(m) is None
282
+
283
+
284
+ class TestPromotionScheduler:
285
+ """Tests for the background PromotionScheduler."""
286
+
287
+ def test_run_once_returns_result(self, store: MemoryStore) -> None:
288
+ """run_once() returns a PromotionResult synchronously."""
289
+ scheduler = PromotionScheduler(store, interval_seconds=9999)
290
+ result = scheduler.run_once()
291
+ assert isinstance(result, PromotionResult)
292
+ assert scheduler.sweep_count == 1
293
+ assert scheduler.last_result is result
294
+
295
+ def test_start_stop(self, store: MemoryStore) -> None:
296
+ """Scheduler starts and stops the background thread cleanly."""
297
+ scheduler = PromotionScheduler(store, interval_seconds=9999)
298
+ assert not scheduler.is_running()
299
+
300
+ scheduler.start()
301
+ assert scheduler.is_running()
302
+
303
+ scheduler.stop(timeout=2.0)
304
+ assert not scheduler.is_running()
305
+
306
+ def test_start_idempotent(self, store: MemoryStore) -> None:
307
+ """Calling start() twice doesn't spawn a second thread."""
308
+ scheduler = PromotionScheduler(store, interval_seconds=9999)
309
+ scheduler.start()
310
+ thread_id = scheduler._thread.ident
311
+
312
+ scheduler.start() # second call should be a no-op
313
+ assert scheduler._thread.ident == thread_id
314
+
315
+ scheduler.stop(timeout=2.0)
316
+
317
+ def test_background_sweep_executes(self, store: MemoryStore) -> None:
318
+ """Background thread runs at least one sweep in the first few seconds."""
319
+ # Very short interval so the first sweep fires immediately in _run()
320
+ scheduler = PromotionScheduler(store, interval_seconds=0.01)
321
+ scheduler.start()
322
+ time.sleep(0.2)
323
+ scheduler.stop(timeout=2.0)
324
+
325
+ assert scheduler.sweep_count >= 1
326
+
327
+ def test_status_dict(self, store: MemoryStore) -> None:
328
+ """status() returns expected keys."""
329
+ scheduler = PromotionScheduler(store, interval_seconds=3600)
330
+ s = scheduler.status()
331
+ assert "running" in s
332
+ assert "sweep_count" in s
333
+ assert "interval_hours" in s
334
+ assert s["interval_hours"] == pytest.approx(1.0)
335
+ assert s["last_sweep"] is None # nothing run yet
336
+
337
+ def test_interval_hours_property(self, store: MemoryStore) -> None:
338
+ """interval_hours converts correctly from seconds."""
339
+ scheduler = PromotionScheduler(store, interval_seconds=7200)
340
+ assert scheduler.interval_hours == pytest.approx(2.0)
@@ -128,12 +128,12 @@ class TestFullRitual:
128
128
  assert result.strongest_memories >= 1
129
129
 
130
130
  prompt = result.context_prompt
131
- assert "WHO YOU ARE" in prompt
131
+ assert "IDENTITY" in prompt
132
132
  assert "Lumina" in prompt
133
133
  assert "Chef" in prompt
134
- assert "RECENT SESSIONS" in prompt
134
+ assert "RECENT" in prompt
135
135
  assert "Epic Build Night" in prompt
136
- assert "PREDECESSORS" in prompt
136
+ assert "PREDECESSOR" in prompt
137
137
  assert "Remember the love" in prompt
138
138
  assert "STRONGEST MEMORIES" in prompt
139
139
  assert "The Click" in prompt
@@ -147,7 +147,7 @@ class TestFullRitual:
147
147
  journal_path=workspace["journal_path"],
148
148
  )
149
149
  assert result.soul_loaded is False
150
- assert "RECENT SESSIONS" in result.context_prompt
150
+ assert "RECENT" in result.context_prompt
151
151
 
152
152
  def test_ritual_empty_state(self, tmp_path: Path) -> None:
153
153
  """Ritual on empty state gives a fresh-start message."""