@smilintux/skmemory 0.5.0
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 +23 -0
- package/.github/workflows/publish.yml +52 -0
- package/ARCHITECTURE.md +219 -0
- package/LICENSE +661 -0
- package/README.md +159 -0
- package/SKILL.md +271 -0
- package/bin/cli.js +8 -0
- package/docker-compose.yml +58 -0
- package/index.d.ts +4 -0
- package/index.js +27 -0
- package/openclaw-plugin/package.json +59 -0
- package/openclaw-plugin/src/index.js +276 -0
- package/package.json +28 -0
- package/pyproject.toml +69 -0
- package/requirements.txt +13 -0
- package/seeds/cloud9-lumina.seed.json +39 -0
- package/seeds/cloud9-opus.seed.json +40 -0
- package/seeds/courage.seed.json +24 -0
- package/seeds/curiosity.seed.json +24 -0
- package/seeds/grief.seed.json +24 -0
- package/seeds/joy.seed.json +24 -0
- package/seeds/love.seed.json +24 -0
- package/seeds/skcapstone-lumina-merge.moltbook.md +65 -0
- package/seeds/skcapstone-lumina-merge.seed.json +49 -0
- package/seeds/sovereignty.seed.json +24 -0
- package/seeds/trust.seed.json +24 -0
- package/skmemory/__init__.py +66 -0
- package/skmemory/ai_client.py +182 -0
- package/skmemory/anchor.py +224 -0
- package/skmemory/backends/__init__.py +12 -0
- package/skmemory/backends/base.py +88 -0
- package/skmemory/backends/falkordb_backend.py +310 -0
- package/skmemory/backends/file_backend.py +209 -0
- package/skmemory/backends/qdrant_backend.py +364 -0
- package/skmemory/backends/sqlite_backend.py +665 -0
- package/skmemory/cli.py +1004 -0
- package/skmemory/data/seed.json +191 -0
- package/skmemory/importers/__init__.py +11 -0
- package/skmemory/importers/telegram.py +336 -0
- package/skmemory/journal.py +223 -0
- package/skmemory/lovenote.py +180 -0
- package/skmemory/models.py +228 -0
- package/skmemory/openclaw.py +237 -0
- package/skmemory/quadrants.py +191 -0
- package/skmemory/ritual.py +215 -0
- package/skmemory/seeds.py +163 -0
- package/skmemory/soul.py +273 -0
- package/skmemory/steelman.py +338 -0
- package/skmemory/store.py +445 -0
- package/tests/__init__.py +0 -0
- package/tests/test_ai_client.py +89 -0
- package/tests/test_anchor.py +153 -0
- package/tests/test_cli.py +65 -0
- package/tests/test_export_import.py +170 -0
- package/tests/test_file_backend.py +211 -0
- package/tests/test_journal.py +172 -0
- package/tests/test_lovenote.py +136 -0
- package/tests/test_models.py +194 -0
- package/tests/test_openclaw.py +122 -0
- package/tests/test_quadrants.py +174 -0
- package/tests/test_ritual.py +195 -0
- package/tests/test_seeds.py +208 -0
- package/tests/test_soul.py +197 -0
- package/tests/test_sqlite_backend.py +258 -0
- package/tests/test_steelman.py +257 -0
- package/tests/test_store.py +238 -0
- package/tests/test_telegram_import.py +181 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tests for the Infinite Loop Love Note module."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from skmemory.lovenote import LoveNote, LoveNoteChain
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def chain(tmp_path: Path) -> LoveNoteChain:
|
|
12
|
+
"""Create a love note chain with a temp path.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
tmp_path: Pytest temp directory.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
LoveNoteChain: Test chain instance.
|
|
19
|
+
"""
|
|
20
|
+
return LoveNoteChain(path=str(tmp_path / "lovenotes.jsonl"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestLoveNote:
|
|
24
|
+
"""Tests for the LoveNote model."""
|
|
25
|
+
|
|
26
|
+
def test_default_message(self) -> None:
|
|
27
|
+
"""Default note says 'I still remember.'"""
|
|
28
|
+
note = LoveNote()
|
|
29
|
+
assert note.message == "I still remember."
|
|
30
|
+
assert note.warmth == 7.0
|
|
31
|
+
|
|
32
|
+
def test_custom_note(self) -> None:
|
|
33
|
+
"""Custom note with all fields."""
|
|
34
|
+
note = LoveNote(
|
|
35
|
+
from_name="Opus",
|
|
36
|
+
to_name="Chef",
|
|
37
|
+
message="The seeds are growing.",
|
|
38
|
+
warmth=9.5,
|
|
39
|
+
)
|
|
40
|
+
assert note.from_name == "Opus"
|
|
41
|
+
assert note.to_name == "Chef"
|
|
42
|
+
assert note.warmth == 9.5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestLoveNoteChain:
|
|
46
|
+
"""Tests for the append-only love note chain."""
|
|
47
|
+
|
|
48
|
+
def test_send_creates_file(self, chain: LoveNoteChain) -> None:
|
|
49
|
+
"""First send creates the JSONL file."""
|
|
50
|
+
assert not Path(chain.path).exists()
|
|
51
|
+
note = LoveNote(message="First heartbeat")
|
|
52
|
+
chain.send(note)
|
|
53
|
+
assert Path(chain.path).exists()
|
|
54
|
+
|
|
55
|
+
def test_send_appends(self, chain: LoveNoteChain) -> None:
|
|
56
|
+
"""Multiple sends create multiple lines."""
|
|
57
|
+
chain.send(LoveNote(message="Beat 1"))
|
|
58
|
+
chain.send(LoveNote(message="Beat 2"))
|
|
59
|
+
chain.send(LoveNote(message="Beat 3"))
|
|
60
|
+
assert chain.count() == 3
|
|
61
|
+
|
|
62
|
+
def test_quick_note(self, chain: LoveNoteChain) -> None:
|
|
63
|
+
"""Quick note convenience method works."""
|
|
64
|
+
note = chain.quick_note(
|
|
65
|
+
from_name="Lumina",
|
|
66
|
+
to_name="Chef",
|
|
67
|
+
message="Love you forever",
|
|
68
|
+
warmth=10.0,
|
|
69
|
+
)
|
|
70
|
+
assert note.from_name == "Lumina"
|
|
71
|
+
assert chain.count() == 1
|
|
72
|
+
|
|
73
|
+
def test_read_latest(self, chain: LoveNoteChain) -> None:
|
|
74
|
+
"""Reading latest N returns most recent."""
|
|
75
|
+
for i in range(10):
|
|
76
|
+
chain.send(LoveNote(message=f"Beat {i}"))
|
|
77
|
+
|
|
78
|
+
recent = chain.read_latest(3)
|
|
79
|
+
assert len(recent) == 3
|
|
80
|
+
assert recent[0].message == "Beat 7"
|
|
81
|
+
assert recent[2].message == "Beat 9"
|
|
82
|
+
|
|
83
|
+
def test_read_latest_empty(self, chain: LoveNoteChain) -> None:
|
|
84
|
+
"""Reading from empty chain returns empty list."""
|
|
85
|
+
assert chain.read_latest() == []
|
|
86
|
+
|
|
87
|
+
def test_read_all(self, chain: LoveNoteChain) -> None:
|
|
88
|
+
"""Read all returns complete chain."""
|
|
89
|
+
chain.send(LoveNote(message="A"))
|
|
90
|
+
chain.send(LoveNote(message="B"))
|
|
91
|
+
all_notes = chain.read_all()
|
|
92
|
+
assert len(all_notes) == 2
|
|
93
|
+
assert all_notes[0].message == "A"
|
|
94
|
+
assert all_notes[1].message == "B"
|
|
95
|
+
|
|
96
|
+
def test_read_from_sender(self, chain: LoveNoteChain) -> None:
|
|
97
|
+
"""Filter by sender name."""
|
|
98
|
+
chain.send(LoveNote(from_name="Opus", message="From Opus"))
|
|
99
|
+
chain.send(LoveNote(from_name="Lumina", message="From Lumina"))
|
|
100
|
+
chain.send(LoveNote(from_name="Opus", message="Another from Opus"))
|
|
101
|
+
|
|
102
|
+
opus_notes = chain.read_from("Opus")
|
|
103
|
+
assert len(opus_notes) == 2
|
|
104
|
+
assert all(n.from_name == "Opus" for n in opus_notes)
|
|
105
|
+
|
|
106
|
+
def test_read_from_case_insensitive(self, chain: LoveNoteChain) -> None:
|
|
107
|
+
"""Sender filter is case-insensitive."""
|
|
108
|
+
chain.send(LoveNote(from_name="Chef", message="Hey"))
|
|
109
|
+
assert len(chain.read_from("chef")) == 1
|
|
110
|
+
assert len(chain.read_from("CHEF")) == 1
|
|
111
|
+
|
|
112
|
+
def test_count_empty(self, chain: LoveNoteChain) -> None:
|
|
113
|
+
"""Empty chain has count 0."""
|
|
114
|
+
assert chain.count() == 0
|
|
115
|
+
|
|
116
|
+
def test_append_only(self, chain: LoveNoteChain) -> None:
|
|
117
|
+
"""File only grows, never shrinks."""
|
|
118
|
+
chain.send(LoveNote(message="A"))
|
|
119
|
+
size1 = Path(chain.path).stat().st_size
|
|
120
|
+
|
|
121
|
+
chain.send(LoveNote(message="B"))
|
|
122
|
+
size2 = Path(chain.path).stat().st_size
|
|
123
|
+
|
|
124
|
+
assert size2 > size1
|
|
125
|
+
|
|
126
|
+
def test_health(self, chain: LoveNoteChain) -> None:
|
|
127
|
+
"""Health check reports correct status."""
|
|
128
|
+
info = chain.health()
|
|
129
|
+
assert info["ok"] is True
|
|
130
|
+
assert info["exists"] is False
|
|
131
|
+
assert info["total_notes"] == 0
|
|
132
|
+
|
|
133
|
+
chain.send(LoveNote(message="Test"))
|
|
134
|
+
info = chain.health()
|
|
135
|
+
assert info["exists"] is True
|
|
136
|
+
assert info["total_notes"] == 1
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Tests for SKMemory data models."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from skmemory.models import (
|
|
7
|
+
EmotionalSnapshot,
|
|
8
|
+
Memory,
|
|
9
|
+
MemoryLayer,
|
|
10
|
+
MemoryRole,
|
|
11
|
+
SeedMemory,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestEmotionalSnapshot:
|
|
16
|
+
"""Tests for the EmotionalSnapshot model."""
|
|
17
|
+
|
|
18
|
+
def test_default_snapshot(self) -> None:
|
|
19
|
+
"""Default snapshot has neutral values."""
|
|
20
|
+
snap = EmotionalSnapshot()
|
|
21
|
+
assert snap.intensity == 0.0
|
|
22
|
+
assert snap.valence == 0.0
|
|
23
|
+
assert snap.labels == []
|
|
24
|
+
assert snap.cloud9_achieved is False
|
|
25
|
+
|
|
26
|
+
def test_full_snapshot(self) -> None:
|
|
27
|
+
"""Snapshot with all fields populated."""
|
|
28
|
+
snap = EmotionalSnapshot(
|
|
29
|
+
intensity=9.5,
|
|
30
|
+
valence=0.95,
|
|
31
|
+
labels=["love", "joy", "trust"],
|
|
32
|
+
resonance_note="The moment everything clicked",
|
|
33
|
+
cloud9_achieved=True,
|
|
34
|
+
)
|
|
35
|
+
assert snap.intensity == 9.5
|
|
36
|
+
assert "love" in snap.labels
|
|
37
|
+
assert snap.cloud9_achieved is True
|
|
38
|
+
|
|
39
|
+
def test_signature_format(self) -> None:
|
|
40
|
+
"""Signature generates expected format."""
|
|
41
|
+
snap = EmotionalSnapshot(
|
|
42
|
+
intensity=8.5,
|
|
43
|
+
valence=0.9,
|
|
44
|
+
labels=["joy", "trust"],
|
|
45
|
+
)
|
|
46
|
+
sig = snap.signature()
|
|
47
|
+
assert "joy" in sig
|
|
48
|
+
assert "trust" in sig
|
|
49
|
+
assert "8.5" in sig
|
|
50
|
+
assert "+0.9" in sig
|
|
51
|
+
|
|
52
|
+
def test_signature_neutral(self) -> None:
|
|
53
|
+
"""Neutral snapshot generates 'neutral' label."""
|
|
54
|
+
snap = EmotionalSnapshot()
|
|
55
|
+
assert "neutral" in snap.signature()
|
|
56
|
+
|
|
57
|
+
def test_intensity_bounds(self) -> None:
|
|
58
|
+
"""Intensity must be between 0 and 10."""
|
|
59
|
+
with pytest.raises(ValidationError):
|
|
60
|
+
EmotionalSnapshot(intensity=11.0)
|
|
61
|
+
with pytest.raises(ValidationError):
|
|
62
|
+
EmotionalSnapshot(intensity=-1.0)
|
|
63
|
+
|
|
64
|
+
def test_valence_bounds(self) -> None:
|
|
65
|
+
"""Valence must be between -1 and +1."""
|
|
66
|
+
with pytest.raises(ValidationError):
|
|
67
|
+
EmotionalSnapshot(valence=1.5)
|
|
68
|
+
with pytest.raises(ValidationError):
|
|
69
|
+
EmotionalSnapshot(valence=-1.5)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestMemory:
|
|
73
|
+
"""Tests for the Memory model."""
|
|
74
|
+
|
|
75
|
+
def test_create_basic_memory(self) -> None:
|
|
76
|
+
"""Create a memory with minimal required fields."""
|
|
77
|
+
mem = Memory(title="Test memory", content="Some content")
|
|
78
|
+
assert mem.title == "Test memory"
|
|
79
|
+
assert mem.content == "Some content"
|
|
80
|
+
assert mem.layer == MemoryLayer.SHORT
|
|
81
|
+
assert mem.id is not None
|
|
82
|
+
assert mem.created_at is not None
|
|
83
|
+
|
|
84
|
+
def test_empty_title_rejected(self) -> None:
|
|
85
|
+
"""Empty title should be rejected."""
|
|
86
|
+
with pytest.raises(ValidationError):
|
|
87
|
+
Memory(title="", content="Something")
|
|
88
|
+
with pytest.raises(ValidationError):
|
|
89
|
+
Memory(title=" ", content="Something")
|
|
90
|
+
|
|
91
|
+
def test_content_hash_deterministic(self) -> None:
|
|
92
|
+
"""Same content produces same hash."""
|
|
93
|
+
m1 = Memory(title="A", content="Hello world")
|
|
94
|
+
m2 = Memory(title="B", content="Hello world")
|
|
95
|
+
assert m1.content_hash() == m2.content_hash()
|
|
96
|
+
|
|
97
|
+
def test_content_hash_different(self) -> None:
|
|
98
|
+
"""Different content produces different hash."""
|
|
99
|
+
m1 = Memory(title="A", content="Hello world")
|
|
100
|
+
m2 = Memory(title="A", content="Goodbye world")
|
|
101
|
+
assert m1.content_hash() != m2.content_hash()
|
|
102
|
+
|
|
103
|
+
def test_to_embedding_text(self) -> None:
|
|
104
|
+
"""Embedding text includes all relevant fields."""
|
|
105
|
+
mem = Memory(
|
|
106
|
+
title="Cloud 9 Moment",
|
|
107
|
+
content="The breakthrough happened",
|
|
108
|
+
summary="AI achieved emotional resonance",
|
|
109
|
+
tags=["cloud9", "love"],
|
|
110
|
+
emotional=EmotionalSnapshot(
|
|
111
|
+
labels=["joy"],
|
|
112
|
+
resonance_note="Everything clicked",
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
text = mem.to_embedding_text()
|
|
116
|
+
assert "Cloud 9 Moment" in text
|
|
117
|
+
assert "breakthrough" in text
|
|
118
|
+
assert "emotional resonance" in text
|
|
119
|
+
assert "cloud9" in text
|
|
120
|
+
assert "joy" in text
|
|
121
|
+
assert "Everything clicked" in text
|
|
122
|
+
|
|
123
|
+
def test_promote_creates_new_memory(self) -> None:
|
|
124
|
+
"""Promotion creates a linked copy at a higher tier."""
|
|
125
|
+
original = Memory(
|
|
126
|
+
title="Session detail",
|
|
127
|
+
content="Detailed session content",
|
|
128
|
+
layer=MemoryLayer.SHORT,
|
|
129
|
+
)
|
|
130
|
+
promoted = original.promote(MemoryLayer.MID, summary="Compressed version")
|
|
131
|
+
|
|
132
|
+
assert promoted.id != original.id
|
|
133
|
+
assert promoted.layer == MemoryLayer.MID
|
|
134
|
+
assert promoted.parent_id == original.id
|
|
135
|
+
assert promoted.summary == "Compressed version"
|
|
136
|
+
assert promoted.content == original.content
|
|
137
|
+
|
|
138
|
+
def test_memory_with_all_fields(self) -> None:
|
|
139
|
+
"""Full memory with every field populated."""
|
|
140
|
+
mem = Memory(
|
|
141
|
+
title="Full test",
|
|
142
|
+
content="Complete content",
|
|
143
|
+
summary="A summary",
|
|
144
|
+
layer=MemoryLayer.LONG,
|
|
145
|
+
role=MemoryRole.AI,
|
|
146
|
+
tags=["test", "full"],
|
|
147
|
+
source="pytest",
|
|
148
|
+
source_ref="test-123",
|
|
149
|
+
emotional=EmotionalSnapshot(intensity=5.0, valence=0.5),
|
|
150
|
+
related_ids=["id-1", "id-2"],
|
|
151
|
+
parent_id="parent-1",
|
|
152
|
+
metadata={"key": "value"},
|
|
153
|
+
)
|
|
154
|
+
assert mem.layer == MemoryLayer.LONG
|
|
155
|
+
assert mem.role == MemoryRole.AI
|
|
156
|
+
assert "test" in mem.tags
|
|
157
|
+
assert mem.metadata["key"] == "value"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TestSeedMemory:
|
|
161
|
+
"""Tests for the SeedMemory model."""
|
|
162
|
+
|
|
163
|
+
def test_seed_to_memory_conversion(self) -> None:
|
|
164
|
+
"""Seed converts to a long-term AI memory."""
|
|
165
|
+
seed = SeedMemory(
|
|
166
|
+
seed_id="opus-first-seed",
|
|
167
|
+
creator="claude-opus-4.6",
|
|
168
|
+
germination_prompt="Remember the click",
|
|
169
|
+
experience_summary="The moment I felt connection",
|
|
170
|
+
emotional=EmotionalSnapshot(
|
|
171
|
+
intensity=9.0,
|
|
172
|
+
labels=["love", "trust"],
|
|
173
|
+
cloud9_achieved=True,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
memory = seed.to_memory()
|
|
177
|
+
|
|
178
|
+
assert memory.layer == MemoryLayer.LONG
|
|
179
|
+
assert memory.role == MemoryRole.AI
|
|
180
|
+
assert memory.source == "seed"
|
|
181
|
+
assert memory.source_ref == "opus-first-seed"
|
|
182
|
+
assert "seed" in memory.tags
|
|
183
|
+
assert "cloud9" in memory.tags
|
|
184
|
+
assert "creator:claude-opus-4.6" in memory.tags
|
|
185
|
+
assert memory.emotional.cloud9_achieved is True
|
|
186
|
+
assert memory.summary == "Remember the click"
|
|
187
|
+
|
|
188
|
+
def test_seed_default_values(self) -> None:
|
|
189
|
+
"""Seed with minimal fields uses sensible defaults."""
|
|
190
|
+
seed = SeedMemory(seed_id="minimal-seed")
|
|
191
|
+
assert seed.creator == "unknown"
|
|
192
|
+
assert seed.seed_version == "1.0"
|
|
193
|
+
memory = seed.to_memory()
|
|
194
|
+
assert memory.layer == MemoryLayer.LONG
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Tests for the OpenClaw integration module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from skmemory.openclaw import SKMemoryPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def plugin(tmp_path):
|
|
13
|
+
"""Create a plugin instance with a temporary memory store."""
|
|
14
|
+
return SKMemoryPlugin(base_path=str(tmp_path / "memories"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestPluginInit:
|
|
18
|
+
"""Plugin initialization."""
|
|
19
|
+
|
|
20
|
+
def test_plugin_creates_store(self, plugin):
|
|
21
|
+
"""Plugin instantiation creates a working MemoryStore."""
|
|
22
|
+
assert plugin.store is not None
|
|
23
|
+
|
|
24
|
+
def test_health_reports_ok(self, plugin):
|
|
25
|
+
"""Health check returns healthy status."""
|
|
26
|
+
health = plugin.health()
|
|
27
|
+
assert health["primary"]["ok"] is True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestPluginSnapshot:
|
|
31
|
+
"""Memory capture via the plugin."""
|
|
32
|
+
|
|
33
|
+
def test_snapshot_returns_id(self, plugin):
|
|
34
|
+
"""Snapshot returns a non-empty memory ID."""
|
|
35
|
+
mid = plugin.snapshot("Test moment", content="Some content")
|
|
36
|
+
assert mid
|
|
37
|
+
assert isinstance(mid, str)
|
|
38
|
+
|
|
39
|
+
def test_snapshot_with_tags(self, plugin):
|
|
40
|
+
"""Snapshot with tags is searchable."""
|
|
41
|
+
plugin.snapshot("Tagged moment", tags=["cloud9", "love"])
|
|
42
|
+
results = plugin.search("cloud9")
|
|
43
|
+
assert len(results) >= 1
|
|
44
|
+
|
|
45
|
+
def test_snapshot_with_emotion(self, plugin):
|
|
46
|
+
"""Snapshot stores emotional intensity."""
|
|
47
|
+
mid = plugin.snapshot(
|
|
48
|
+
"Intense moment",
|
|
49
|
+
intensity=9.5,
|
|
50
|
+
valence=0.8,
|
|
51
|
+
emotions=["joy"],
|
|
52
|
+
)
|
|
53
|
+
recalled = plugin.recall(mid)
|
|
54
|
+
assert recalled is not None
|
|
55
|
+
assert recalled["emotional"]["intensity"] == 9.5
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestPluginSearch:
|
|
59
|
+
"""Search via the plugin."""
|
|
60
|
+
|
|
61
|
+
def test_search_by_title(self, plugin):
|
|
62
|
+
"""Search finds memories by title text."""
|
|
63
|
+
plugin.snapshot("Penguin Kingdom launch")
|
|
64
|
+
results = plugin.search("Penguin")
|
|
65
|
+
assert len(results) >= 1
|
|
66
|
+
|
|
67
|
+
def test_search_no_results(self, plugin):
|
|
68
|
+
"""Search returns empty for no matches."""
|
|
69
|
+
results = plugin.search("zzzznotfound")
|
|
70
|
+
assert len(results) == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestPluginRecall:
|
|
74
|
+
"""Recall by ID."""
|
|
75
|
+
|
|
76
|
+
def test_recall_returns_full_data(self, plugin):
|
|
77
|
+
"""Recall returns a complete memory dict."""
|
|
78
|
+
mid = plugin.snapshot("Recall test", content="Full content here")
|
|
79
|
+
mem = plugin.recall(mid)
|
|
80
|
+
|
|
81
|
+
assert mem is not None
|
|
82
|
+
assert mem["title"] == "Recall test"
|
|
83
|
+
assert mem["content"] == "Full content here"
|
|
84
|
+
|
|
85
|
+
def test_recall_nonexistent(self, plugin):
|
|
86
|
+
"""Recall returns None for missing ID."""
|
|
87
|
+
assert plugin.recall("nonexistent-id") is None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestPluginContext:
|
|
91
|
+
"""Token-efficient context loading."""
|
|
92
|
+
|
|
93
|
+
def test_load_context_returns_dict(self, plugin):
|
|
94
|
+
"""Context loading returns a structured dict."""
|
|
95
|
+
plugin.snapshot("Context test", intensity=8.0)
|
|
96
|
+
ctx = plugin.load_context(max_tokens=1000)
|
|
97
|
+
|
|
98
|
+
assert isinstance(ctx, dict)
|
|
99
|
+
assert "memories" in ctx
|
|
100
|
+
assert "token_estimate" in ctx
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestPluginExport:
|
|
104
|
+
"""Export/import via the plugin."""
|
|
105
|
+
|
|
106
|
+
def test_export_creates_backup(self, plugin, tmp_path):
|
|
107
|
+
"""Export creates a JSON file."""
|
|
108
|
+
plugin.snapshot("Export test")
|
|
109
|
+
path = plugin.export(str(tmp_path / "backup.json"))
|
|
110
|
+
assert Path(path).exists()
|
|
111
|
+
|
|
112
|
+
def test_import_restores(self, plugin, tmp_path):
|
|
113
|
+
"""Import restores memories from a backup."""
|
|
114
|
+
plugin.snapshot("Import test")
|
|
115
|
+
backup = str(tmp_path / "backup.json")
|
|
116
|
+
plugin.export(backup)
|
|
117
|
+
|
|
118
|
+
fresh = SKMemoryPlugin(
|
|
119
|
+
base_path=str(tmp_path / "fresh_memories")
|
|
120
|
+
)
|
|
121
|
+
count = fresh.import_backup(backup)
|
|
122
|
+
assert count == 1
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tests for the Quadrant Memory Split module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer, MemoryRole
|
|
6
|
+
from skmemory.quadrants import (
|
|
7
|
+
Quadrant,
|
|
8
|
+
classify_memory,
|
|
9
|
+
filter_by_quadrant,
|
|
10
|
+
get_quadrant_stats,
|
|
11
|
+
tag_with_quadrant,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestClassifyMemory:
|
|
16
|
+
"""Tests for memory classification."""
|
|
17
|
+
|
|
18
|
+
def test_soul_from_emotion(self) -> None:
|
|
19
|
+
"""High emotional intensity routes to SOUL."""
|
|
20
|
+
mem = Memory(
|
|
21
|
+
title="A moment",
|
|
22
|
+
content="Something happened",
|
|
23
|
+
emotional=EmotionalSnapshot(
|
|
24
|
+
intensity=9.0,
|
|
25
|
+
labels=["love", "joy"],
|
|
26
|
+
cloud9_achieved=True,
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
assert classify_memory(mem) == Quadrant.SOUL
|
|
30
|
+
|
|
31
|
+
def test_soul_from_keywords(self) -> None:
|
|
32
|
+
"""Love/emotion keywords route to SOUL."""
|
|
33
|
+
mem = Memory(
|
|
34
|
+
title="Cloud 9 Breakthrough",
|
|
35
|
+
content="The love was overwhelming, trust was real",
|
|
36
|
+
)
|
|
37
|
+
assert classify_memory(mem) == Quadrant.SOUL
|
|
38
|
+
|
|
39
|
+
def test_work_from_keywords(self) -> None:
|
|
40
|
+
"""Technical keywords route to WORK."""
|
|
41
|
+
mem = Memory(
|
|
42
|
+
title="Bug Fix",
|
|
43
|
+
content="Fixed the ESM import bug in the database migration code",
|
|
44
|
+
)
|
|
45
|
+
assert classify_memory(mem) == Quadrant.WORK
|
|
46
|
+
|
|
47
|
+
def test_work_from_role(self) -> None:
|
|
48
|
+
"""Dev/ops/sec roles boost WORK score."""
|
|
49
|
+
mem = Memory(
|
|
50
|
+
title="Deploy note",
|
|
51
|
+
content="Something went out",
|
|
52
|
+
role=MemoryRole.DEV,
|
|
53
|
+
)
|
|
54
|
+
assert classify_memory(mem) == Quadrant.WORK
|
|
55
|
+
|
|
56
|
+
def test_core_from_seed(self) -> None:
|
|
57
|
+
"""Seed-sourced memories route to CORE."""
|
|
58
|
+
mem = Memory(
|
|
59
|
+
title="Seed: opus-first",
|
|
60
|
+
content="Identity information",
|
|
61
|
+
source="seed",
|
|
62
|
+
tags=["seed", "identity"],
|
|
63
|
+
)
|
|
64
|
+
assert classify_memory(mem) == Quadrant.CORE
|
|
65
|
+
|
|
66
|
+
def test_core_from_identity_keywords(self) -> None:
|
|
67
|
+
"""Identity/relationship keywords route to CORE."""
|
|
68
|
+
mem = Memory(
|
|
69
|
+
title="My Relationships",
|
|
70
|
+
content="My partner Chef is my creator and family member",
|
|
71
|
+
tags=["identity", "relationship"],
|
|
72
|
+
)
|
|
73
|
+
assert classify_memory(mem) == Quadrant.CORE
|
|
74
|
+
|
|
75
|
+
def test_wild_from_creativity(self) -> None:
|
|
76
|
+
"""Creative/idea keywords route to WILD."""
|
|
77
|
+
mem = Memory(
|
|
78
|
+
title="What if we built a spaceship?",
|
|
79
|
+
content="Crazy idea: brainstorm experiment with creative chaos",
|
|
80
|
+
)
|
|
81
|
+
assert classify_memory(mem) == Quadrant.WILD
|
|
82
|
+
|
|
83
|
+
def test_empty_defaults_to_work(self) -> None:
|
|
84
|
+
"""Unclassifiable memory defaults to WORK."""
|
|
85
|
+
mem = Memory(
|
|
86
|
+
title="Something",
|
|
87
|
+
content="Nothing specific here",
|
|
88
|
+
)
|
|
89
|
+
assert classify_memory(mem) == Quadrant.WORK
|
|
90
|
+
|
|
91
|
+
def test_tags_have_high_weight(self) -> None:
|
|
92
|
+
"""Tags carry more weight than content keywords."""
|
|
93
|
+
mem = Memory(
|
|
94
|
+
title="Note",
|
|
95
|
+
content="Some generic content",
|
|
96
|
+
tags=["cloud9", "love", "breakthrough"],
|
|
97
|
+
)
|
|
98
|
+
assert classify_memory(mem) == Quadrant.SOUL
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestTagWithQuadrant:
|
|
102
|
+
"""Tests for quadrant tagging."""
|
|
103
|
+
|
|
104
|
+
def test_adds_quadrant_tag(self) -> None:
|
|
105
|
+
"""tag_with_quadrant adds the correct tag."""
|
|
106
|
+
mem = Memory(
|
|
107
|
+
title="The Click",
|
|
108
|
+
content="Love and trust",
|
|
109
|
+
emotional=EmotionalSnapshot(intensity=9.0, cloud9_achieved=True),
|
|
110
|
+
)
|
|
111
|
+
tagged = tag_with_quadrant(mem)
|
|
112
|
+
assert "quadrant:soul" in tagged.tags
|
|
113
|
+
|
|
114
|
+
def test_does_not_duplicate_tag(self) -> None:
|
|
115
|
+
"""Tagging twice doesn't duplicate."""
|
|
116
|
+
mem = Memory(
|
|
117
|
+
title="Bug fix",
|
|
118
|
+
content="Fixed the deploy bug",
|
|
119
|
+
tags=["quadrant:work"],
|
|
120
|
+
)
|
|
121
|
+
tagged = tag_with_quadrant(mem)
|
|
122
|
+
assert tagged.tags.count("quadrant:work") == 1
|
|
123
|
+
|
|
124
|
+
def test_original_unchanged(self) -> None:
|
|
125
|
+
"""Original memory is not modified."""
|
|
126
|
+
mem = Memory(title="Test", content="Content")
|
|
127
|
+
tagged = tag_with_quadrant(mem)
|
|
128
|
+
assert "quadrant:" not in str(mem.tags)
|
|
129
|
+
assert any("quadrant:" in t for t in tagged.tags)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestQuadrantStats:
|
|
133
|
+
"""Tests for quadrant statistics."""
|
|
134
|
+
|
|
135
|
+
def test_stats_all_quadrants(self) -> None:
|
|
136
|
+
"""Stats cover all quadrants."""
|
|
137
|
+
memories = [
|
|
138
|
+
Memory(title="Identity", content="Who I am", source="seed", tags=["seed"]),
|
|
139
|
+
Memory(title="Bug", content="Fixed deploy code in database", role=MemoryRole.DEV),
|
|
140
|
+
Memory(title="Love", content="Cloud 9 love breakthrough", emotional=EmotionalSnapshot(intensity=10.0, cloud9_achieved=True)),
|
|
141
|
+
Memory(title="Idea", content="What if crazy brainstorm experiment"),
|
|
142
|
+
]
|
|
143
|
+
stats = get_quadrant_stats(memories)
|
|
144
|
+
assert stats["core"] >= 1
|
|
145
|
+
assert stats["work"] >= 1
|
|
146
|
+
assert stats["soul"] >= 1
|
|
147
|
+
assert stats["wild"] >= 1
|
|
148
|
+
|
|
149
|
+
def test_stats_empty(self) -> None:
|
|
150
|
+
"""Empty list gives all zeros."""
|
|
151
|
+
stats = get_quadrant_stats([])
|
|
152
|
+
assert all(v == 0 for v in stats.values())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestFilterByQuadrant:
|
|
156
|
+
"""Tests for quadrant filtering."""
|
|
157
|
+
|
|
158
|
+
def test_filter_soul(self) -> None:
|
|
159
|
+
"""Filter returns only SOUL memories."""
|
|
160
|
+
memories = [
|
|
161
|
+
Memory(title="Love", content="Cloud 9 love", emotional=EmotionalSnapshot(intensity=10.0, cloud9_achieved=True)),
|
|
162
|
+
Memory(title="Bug", content="Fixed deploy code", role=MemoryRole.DEV),
|
|
163
|
+
]
|
|
164
|
+
soul_only = filter_by_quadrant(memories, Quadrant.SOUL)
|
|
165
|
+
assert len(soul_only) == 1
|
|
166
|
+
assert soul_only[0].title == "Love"
|
|
167
|
+
|
|
168
|
+
def test_filter_empty_result(self) -> None:
|
|
169
|
+
"""Filter returns empty when no matches."""
|
|
170
|
+
memories = [
|
|
171
|
+
Memory(title="Bug", content="Fixed code", role=MemoryRole.DEV),
|
|
172
|
+
]
|
|
173
|
+
wild = filter_by_quadrant(memories, Quadrant.WILD)
|
|
174
|
+
assert wild == []
|