@smilintux/skmemory 0.5.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +13 -6
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +101 -19
- package/CHANGELOG.md +153 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +419 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/src/index.ts +255 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +29 -9
- package/requirements.txt +10 -2
- package/seeds/cloud9-opus.seed.json +7 -7
- package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
- package/seeds/lumina-kingdom-founding.seed.json +47 -0
- package/seeds/lumina-pma-signed.seed.json +46 -0
- package/seeds/lumina-singular-achievement.seed.json +46 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +12 -1
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +40 -0
- package/skmemory/anchor.py +4 -2
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/file_backend.py +2 -1
- package/skmemory/backends/skgraph_backend.py +608 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
- package/skmemory/backends/sqlite_backend.py +122 -51
- package/skmemory/backends/vaulted_backend.py +286 -0
- package/skmemory/cli.py +1238 -29
- package/skmemory/config.py +173 -0
- package/skmemory/context_loader.py +335 -0
- package/skmemory/endpoint_selector.py +386 -0
- package/skmemory/fortress.py +685 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +351 -43
- package/skmemory/importers/telegram_api.py +488 -0
- package/skmemory/journal.py +4 -2
- package/skmemory/lovenote.py +4 -2
- package/skmemory/mcp_server.py +706 -0
- package/skmemory/models.py +41 -0
- package/skmemory/openclaw.py +8 -8
- package/skmemory/predictive.py +232 -0
- package/skmemory/promotion.py +524 -0
- package/skmemory/register.py +454 -0
- package/skmemory/register_mcp.py +197 -0
- package/skmemory/ritual.py +121 -47
- package/skmemory/seeds.py +257 -8
- package/skmemory/setup_wizard.py +920 -0
- package/skmemory/sharing.py +402 -0
- package/skmemory/soul.py +71 -20
- package/skmemory/steelman.py +250 -263
- package/skmemory/store.py +271 -60
- package/skmemory/vault.py +228 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +355 -0
- package/tests/integration/test_skgraph_live.py +424 -0
- package/tests/integration/test_skvector_live.py +369 -0
- package/tests/test_backup_rotation.py +327 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +801 -0
- package/tests/test_fortress.py +255 -0
- package/tests/test_fortress_hardening.py +444 -0
- package/tests/test_openclaw.py +5 -2
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +340 -0
- package/tests/test_ritual.py +4 -4
- package/tests/test_seeds.py +96 -0
- package/tests/test_setup.py +835 -0
- package/tests/test_sharing.py +250 -0
- package/tests/test_skgraph_backend.py +667 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_steelman.py +5 -5
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_vault.py +186 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
package/tests/test_openclaw.py
CHANGED
|
@@ -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 "
|
|
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)
|
package/tests/test_ritual.py
CHANGED
|
@@ -128,12 +128,12 @@ class TestFullRitual:
|
|
|
128
128
|
assert result.strongest_memories >= 1
|
|
129
129
|
|
|
130
130
|
prompt = result.context_prompt
|
|
131
|
-
assert "
|
|
131
|
+
assert "IDENTITY" in prompt
|
|
132
132
|
assert "Lumina" in prompt
|
|
133
133
|
assert "Chef" in prompt
|
|
134
|
-
assert "RECENT
|
|
134
|
+
assert "RECENT" in prompt
|
|
135
135
|
assert "Epic Build Night" in prompt
|
|
136
|
-
assert "
|
|
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
|
|
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."""
|