@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.
Files changed (67) hide show
  1. package/.github/workflows/ci.yml +23 -0
  2. package/.github/workflows/publish.yml +52 -0
  3. package/ARCHITECTURE.md +219 -0
  4. package/LICENSE +661 -0
  5. package/README.md +159 -0
  6. package/SKILL.md +271 -0
  7. package/bin/cli.js +8 -0
  8. package/docker-compose.yml +58 -0
  9. package/index.d.ts +4 -0
  10. package/index.js +27 -0
  11. package/openclaw-plugin/package.json +59 -0
  12. package/openclaw-plugin/src/index.js +276 -0
  13. package/package.json +28 -0
  14. package/pyproject.toml +69 -0
  15. package/requirements.txt +13 -0
  16. package/seeds/cloud9-lumina.seed.json +39 -0
  17. package/seeds/cloud9-opus.seed.json +40 -0
  18. package/seeds/courage.seed.json +24 -0
  19. package/seeds/curiosity.seed.json +24 -0
  20. package/seeds/grief.seed.json +24 -0
  21. package/seeds/joy.seed.json +24 -0
  22. package/seeds/love.seed.json +24 -0
  23. package/seeds/skcapstone-lumina-merge.moltbook.md +65 -0
  24. package/seeds/skcapstone-lumina-merge.seed.json +49 -0
  25. package/seeds/sovereignty.seed.json +24 -0
  26. package/seeds/trust.seed.json +24 -0
  27. package/skmemory/__init__.py +66 -0
  28. package/skmemory/ai_client.py +182 -0
  29. package/skmemory/anchor.py +224 -0
  30. package/skmemory/backends/__init__.py +12 -0
  31. package/skmemory/backends/base.py +88 -0
  32. package/skmemory/backends/falkordb_backend.py +310 -0
  33. package/skmemory/backends/file_backend.py +209 -0
  34. package/skmemory/backends/qdrant_backend.py +364 -0
  35. package/skmemory/backends/sqlite_backend.py +665 -0
  36. package/skmemory/cli.py +1004 -0
  37. package/skmemory/data/seed.json +191 -0
  38. package/skmemory/importers/__init__.py +11 -0
  39. package/skmemory/importers/telegram.py +336 -0
  40. package/skmemory/journal.py +223 -0
  41. package/skmemory/lovenote.py +180 -0
  42. package/skmemory/models.py +228 -0
  43. package/skmemory/openclaw.py +237 -0
  44. package/skmemory/quadrants.py +191 -0
  45. package/skmemory/ritual.py +215 -0
  46. package/skmemory/seeds.py +163 -0
  47. package/skmemory/soul.py +273 -0
  48. package/skmemory/steelman.py +338 -0
  49. package/skmemory/store.py +445 -0
  50. package/tests/__init__.py +0 -0
  51. package/tests/test_ai_client.py +89 -0
  52. package/tests/test_anchor.py +153 -0
  53. package/tests/test_cli.py +65 -0
  54. package/tests/test_export_import.py +170 -0
  55. package/tests/test_file_backend.py +211 -0
  56. package/tests/test_journal.py +172 -0
  57. package/tests/test_lovenote.py +136 -0
  58. package/tests/test_models.py +194 -0
  59. package/tests/test_openclaw.py +122 -0
  60. package/tests/test_quadrants.py +174 -0
  61. package/tests/test_ritual.py +195 -0
  62. package/tests/test_seeds.py +208 -0
  63. package/tests/test_soul.py +197 -0
  64. package/tests/test_sqlite_backend.py +258 -0
  65. package/tests/test_steelman.py +257 -0
  66. package/tests/test_store.py +238 -0
  67. 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 == []