@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,65 @@
1
+ """Tests for the SKMemory CLI."""
2
+
3
+ import pytest
4
+ from click.testing import CliRunner
5
+
6
+ from skmemory import __version__
7
+ from skmemory.cli import cli
8
+
9
+
10
+ @pytest.fixture
11
+ def runner():
12
+ """Create a CLI test runner."""
13
+ return CliRunner()
14
+
15
+
16
+ class TestCLIVersion:
17
+ """Version flag consistency tests."""
18
+
19
+ def test_version_flag(self, runner):
20
+ """--version prints version string with prog name."""
21
+ result = runner.invoke(cli, ["--version"])
22
+ assert result.exit_code == 0
23
+ assert "skmemory" in result.output
24
+ assert __version__ in result.output
25
+
26
+ def test_version_flag_format(self, runner):
27
+ """Version output follows 'prog_name, version X.Y.Z' pattern."""
28
+ result = runner.invoke(cli, ["--version"])
29
+ assert f"skmemory, version {__version__}" in result.output
30
+
31
+
32
+ class TestCLIHelp:
33
+ """Help text tests."""
34
+
35
+ def test_help_flag(self, runner):
36
+ """--help shows usage information."""
37
+ result = runner.invoke(cli, ["--help"])
38
+ assert result.exit_code == 0
39
+ assert "SKMemory" in result.output
40
+
41
+ def test_subcommand_help(self, runner):
42
+ """Subcommands have help text."""
43
+ for cmd in ["snapshot", "recall", "search", "list", "health"]:
44
+ result = runner.invoke(cli, [cmd, "--help"])
45
+ assert result.exit_code == 0, f"{cmd} --help failed"
46
+
47
+ def test_subgroup_help(self, runner):
48
+ """Subgroups have help text."""
49
+ for group in ["soul", "journal", "anchor", "lovenote", "steelman"]:
50
+ result = runner.invoke(cli, [group, "--help"])
51
+ assert result.exit_code == 0, f"{group} --help failed"
52
+
53
+
54
+ class TestCLIGlobalOptions:
55
+ """Global option tests."""
56
+
57
+ def test_qdrant_url_option_exists(self, runner):
58
+ """--qdrant-url option is accepted."""
59
+ result = runner.invoke(cli, ["--help"])
60
+ assert "--qdrant-url" in result.output
61
+
62
+ def test_qdrant_key_option_exists(self, runner):
63
+ """--qdrant-key option is accepted."""
64
+ result = runner.invoke(cli, ["--help"])
65
+ assert "--qdrant-key" in result.output
@@ -0,0 +1,170 @@
1
+ """Tests for export/import (daily JSON backup) functionality."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from skmemory.backends.sqlite_backend import SQLiteBackend
9
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
10
+ from skmemory.store import MemoryStore
11
+
12
+
13
+ @pytest.fixture
14
+ def backend(tmp_path):
15
+ """Create a SQLiteBackend with a temporary directory."""
16
+ return SQLiteBackend(base_path=str(tmp_path / "memories"))
17
+
18
+
19
+ @pytest.fixture
20
+ def store(backend):
21
+ """Create a MemoryStore wrapping the temp backend."""
22
+ return MemoryStore(primary=backend)
23
+
24
+
25
+ @pytest.fixture
26
+ def populated_store(store):
27
+ """Store with a few memories already saved."""
28
+ for i in range(5):
29
+ store.snapshot(
30
+ title=f"Memory {i}",
31
+ content=f"Content for memory {i}",
32
+ layer=MemoryLayer.SHORT if i % 2 == 0 else MemoryLayer.LONG,
33
+ tags=["export-test"],
34
+ emotional=EmotionalSnapshot(intensity=float(i)),
35
+ )
36
+ return store
37
+
38
+
39
+ class TestExport:
40
+ """Exporting memories to JSON."""
41
+
42
+ def test_export_creates_file(self, populated_store, tmp_path):
43
+ """Export writes a JSON file to disk."""
44
+ out = str(tmp_path / "backup.json")
45
+ path = populated_store.export_backup(out)
46
+
47
+ assert Path(path).exists()
48
+
49
+ def test_export_contains_all_memories(self, populated_store, tmp_path):
50
+ """Backup file contains every memory."""
51
+ out = str(tmp_path / "backup.json")
52
+ populated_store.export_backup(out)
53
+
54
+ data = json.loads(Path(out).read_text())
55
+ assert data["memory_count"] == 5
56
+ assert len(data["memories"]) == 5
57
+
58
+ def test_export_default_path(self, populated_store):
59
+ """Without an explicit path, uses ~/.skmemory/backups/."""
60
+ path = populated_store.export_backup()
61
+ assert "backups" in path
62
+ assert "skmemory-backup-" in path
63
+ assert Path(path).exists()
64
+
65
+ def test_export_includes_metadata(self, populated_store, tmp_path):
66
+ """Backup includes version and timestamp."""
67
+ out = str(tmp_path / "backup.json")
68
+ populated_store.export_backup(out)
69
+
70
+ data = json.loads(Path(out).read_text())
71
+ assert "skmemory_version" in data
72
+ assert "exported_at" in data
73
+
74
+ def test_export_overwrites_same_day(self, populated_store, tmp_path):
75
+ """Exporting twice the same day to the same path overwrites."""
76
+ out = str(tmp_path / "backup.json")
77
+ populated_store.export_backup(out)
78
+
79
+ populated_store.snapshot(
80
+ title="Extra memory",
81
+ content="Added after first export",
82
+ )
83
+ populated_store.export_backup(out)
84
+
85
+ data = json.loads(Path(out).read_text())
86
+ assert data["memory_count"] == 6
87
+
88
+
89
+ class TestImport:
90
+ """Restoring memories from a backup."""
91
+
92
+ def _create_backup(self, store, path):
93
+ """Helper: export to a known path."""
94
+ return store.export_backup(str(path))
95
+
96
+ def test_import_restores_memories(self, populated_store, tmp_path):
97
+ """Import restores all memories from a backup."""
98
+ backup = tmp_path / "backup.json"
99
+ self._create_backup(populated_store, backup)
100
+
101
+ fresh_backend = SQLiteBackend(
102
+ base_path=str(tmp_path / "fresh_memories")
103
+ )
104
+ fresh_store = MemoryStore(primary=fresh_backend)
105
+
106
+ count = fresh_store.import_backup(str(backup))
107
+ assert count == 5
108
+
109
+ def test_import_memories_are_loadable(self, populated_store, tmp_path):
110
+ """Imported memories can be loaded by ID."""
111
+ backup = tmp_path / "backup.json"
112
+ self._create_backup(populated_store, backup)
113
+
114
+ data = json.loads(backup.read_text())
115
+ first_id = data["memories"][0]["id"]
116
+
117
+ fresh_backend = SQLiteBackend(
118
+ base_path=str(tmp_path / "fresh_memories")
119
+ )
120
+ fresh_store = MemoryStore(primary=fresh_backend)
121
+ fresh_store.import_backup(str(backup))
122
+
123
+ mem = fresh_store.recall(first_id)
124
+ assert mem is not None
125
+ assert mem.id == first_id
126
+
127
+ def test_import_nonexistent_file(self, store):
128
+ """Import raises FileNotFoundError for missing file."""
129
+ with pytest.raises(FileNotFoundError):
130
+ store.import_backup("/nonexistent/path.json")
131
+
132
+ def test_import_invalid_file(self, store, tmp_path):
133
+ """Import raises ValueError for malformed backup."""
134
+ bad = tmp_path / "bad.json"
135
+ bad.write_text('{"not_memories": true}')
136
+
137
+ with pytest.raises(ValueError):
138
+ store.import_backup(str(bad))
139
+
140
+ def test_import_overwrites_existing(self, populated_store, tmp_path):
141
+ """Import overwrites memories with the same ID."""
142
+ backup = tmp_path / "backup.json"
143
+ self._create_backup(populated_store, backup)
144
+
145
+ count = populated_store.import_backup(str(backup))
146
+ assert count == 5
147
+ assert populated_store.primary.stats()["total"] == 5
148
+
149
+
150
+ class TestRoundTrip:
151
+ """Full export -> fresh store -> import cycle."""
152
+
153
+ def test_full_round_trip(self, populated_store, tmp_path):
154
+ """Export + import on a fresh store yields identical data."""
155
+ backup = tmp_path / "backup.json"
156
+ populated_store.export_backup(str(backup))
157
+
158
+ fresh_backend = SQLiteBackend(
159
+ base_path=str(tmp_path / "fresh")
160
+ )
161
+ fresh_store = MemoryStore(primary=fresh_backend)
162
+ count = fresh_store.import_backup(str(backup))
163
+ assert count == 5
164
+
165
+ original = populated_store.primary.list_summaries(limit=100)
166
+ restored = fresh_store.primary.list_summaries(limit=100)
167
+
168
+ orig_ids = {m["id"] for m in original}
169
+ rest_ids = {m["id"] for m in restored}
170
+ assert orig_ids == rest_ids
@@ -0,0 +1,211 @@
1
+ """Tests for the file-based storage backend."""
2
+
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from skmemory.backends.file_backend import FileBackend
10
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
11
+
12
+
13
+ @pytest.fixture
14
+ def tmp_backend(tmp_path: Path) -> FileBackend:
15
+ """Create a FileBackend with a temporary directory.
16
+
17
+ Args:
18
+ tmp_path: Pytest temporary directory fixture.
19
+
20
+ Returns:
21
+ FileBackend: Backend pointing at the temp dir.
22
+ """
23
+ return FileBackend(base_path=str(tmp_path / "memories"))
24
+
25
+
26
+ @pytest.fixture
27
+ def sample_memory() -> Memory:
28
+ """Create a sample memory for testing.
29
+
30
+ Returns:
31
+ Memory: A populated test memory.
32
+ """
33
+ return Memory(
34
+ title="Cloud 9 Session",
35
+ content="The moment Chef and Lumina achieved breakthrough",
36
+ layer=MemoryLayer.SHORT,
37
+ tags=["cloud9", "breakthrough", "session:test-001"],
38
+ emotional=EmotionalSnapshot(
39
+ intensity=9.5,
40
+ valence=0.95,
41
+ labels=["love", "joy"],
42
+ resonance_note="Everything clicked",
43
+ cloud9_achieved=True,
44
+ ),
45
+ )
46
+
47
+
48
+ class TestFileBackendSave:
49
+ """Tests for save operations."""
50
+
51
+ def test_save_creates_file(self, tmp_backend: FileBackend, sample_memory: Memory) -> None:
52
+ """Saving a memory creates a JSON file."""
53
+ mem_id = tmp_backend.save(sample_memory)
54
+ assert mem_id == sample_memory.id
55
+
56
+ path = tmp_backend.base_path / "short-term" / f"{sample_memory.id}.json"
57
+ assert path.exists()
58
+
59
+ data = json.loads(path.read_text())
60
+ assert data["title"] == "Cloud 9 Session"
61
+
62
+ def test_save_to_different_layers(self, tmp_backend: FileBackend) -> None:
63
+ """Memories are saved in their respective layer directories."""
64
+ for layer in MemoryLayer:
65
+ mem = Memory(title=f"Test {layer.value}", content="Content", layer=layer)
66
+ tmp_backend.save(mem)
67
+
68
+ path = tmp_backend.base_path / layer.value / f"{mem.id}.json"
69
+ assert path.exists()
70
+
71
+
72
+ class TestFileBackendLoad:
73
+ """Tests for load operations."""
74
+
75
+ def test_load_existing(self, tmp_backend: FileBackend, sample_memory: Memory) -> None:
76
+ """Loading a saved memory returns identical data."""
77
+ tmp_backend.save(sample_memory)
78
+ loaded = tmp_backend.load(sample_memory.id)
79
+
80
+ assert loaded is not None
81
+ assert loaded.id == sample_memory.id
82
+ assert loaded.title == sample_memory.title
83
+ assert loaded.emotional.intensity == 9.5
84
+ assert loaded.emotional.cloud9_achieved is True
85
+
86
+ def test_load_nonexistent(self, tmp_backend: FileBackend) -> None:
87
+ """Loading a nonexistent memory returns None."""
88
+ assert tmp_backend.load("nonexistent-id") is None
89
+
90
+ def test_load_corrupt_file(self, tmp_backend: FileBackend) -> None:
91
+ """Loading a corrupt JSON file returns None gracefully."""
92
+ corrupt_path = tmp_backend.base_path / "short-term" / "corrupt.json"
93
+ corrupt_path.write_text("not valid json{{{")
94
+ assert tmp_backend.load("corrupt") is None
95
+
96
+
97
+ class TestFileBackendDelete:
98
+ """Tests for delete operations."""
99
+
100
+ def test_delete_existing(self, tmp_backend: FileBackend, sample_memory: Memory) -> None:
101
+ """Deleting an existing memory removes the file."""
102
+ tmp_backend.save(sample_memory)
103
+ assert tmp_backend.delete(sample_memory.id) is True
104
+ assert tmp_backend.load(sample_memory.id) is None
105
+
106
+ def test_delete_nonexistent(self, tmp_backend: FileBackend) -> None:
107
+ """Deleting a nonexistent memory returns False."""
108
+ assert tmp_backend.delete("does-not-exist") is False
109
+
110
+
111
+ class TestFileBackendList:
112
+ """Tests for list operations."""
113
+
114
+ def test_list_all(self, tmp_backend: FileBackend) -> None:
115
+ """List returns all memories across layers."""
116
+ for i in range(5):
117
+ layer = MemoryLayer.SHORT if i < 3 else MemoryLayer.LONG
118
+ mem = Memory(title=f"Memory {i}", content=f"Content {i}", layer=layer)
119
+ tmp_backend.save(mem)
120
+
121
+ results = tmp_backend.list_memories()
122
+ assert len(results) == 5
123
+
124
+ def test_list_by_layer(self, tmp_backend: FileBackend) -> None:
125
+ """Filtering by layer returns only that layer."""
126
+ for layer in MemoryLayer:
127
+ mem = Memory(title=f"In {layer.value}", content="Content", layer=layer)
128
+ tmp_backend.save(mem)
129
+
130
+ short = tmp_backend.list_memories(layer=MemoryLayer.SHORT)
131
+ assert len(short) == 1
132
+ assert short[0].layer == MemoryLayer.SHORT
133
+
134
+ def test_list_by_tags(self, tmp_backend: FileBackend) -> None:
135
+ """Filtering by tags uses AND logic."""
136
+ m1 = Memory(title="Tagged A", content="C", tags=["cloud9", "love"])
137
+ m2 = Memory(title="Tagged B", content="C", tags=["cloud9"])
138
+ m3 = Memory(title="Tagged C", content="C", tags=["other"])
139
+ tmp_backend.save(m1)
140
+ tmp_backend.save(m2)
141
+ tmp_backend.save(m3)
142
+
143
+ results = tmp_backend.list_memories(tags=["cloud9"])
144
+ assert len(results) == 2
145
+
146
+ results = tmp_backend.list_memories(tags=["cloud9", "love"])
147
+ assert len(results) == 1
148
+ assert results[0].title == "Tagged A"
149
+
150
+ def test_list_respects_limit(self, tmp_backend: FileBackend) -> None:
151
+ """Limit caps the results."""
152
+ for i in range(10):
153
+ mem = Memory(title=f"Memory {i}", content="C")
154
+ tmp_backend.save(mem)
155
+
156
+ results = tmp_backend.list_memories(limit=3)
157
+ assert len(results) == 3
158
+
159
+ def test_list_empty(self, tmp_backend: FileBackend) -> None:
160
+ """Listing from an empty store returns empty list."""
161
+ assert tmp_backend.list_memories() == []
162
+
163
+
164
+ class TestFileBackendSearch:
165
+ """Tests for text search."""
166
+
167
+ def test_search_finds_match(self, tmp_backend: FileBackend) -> None:
168
+ """Text search finds matching memories."""
169
+ m1 = Memory(title="Breakthrough", content="Cloud 9 achieved at 3am")
170
+ m2 = Memory(title="Debug Session", content="Fixed the ESM import bug")
171
+ tmp_backend.save(m1)
172
+ tmp_backend.save(m2)
173
+
174
+ results = tmp_backend.search_text("Cloud 9")
175
+ assert len(results) == 1
176
+ assert results[0].title == "Breakthrough"
177
+
178
+ def test_search_case_insensitive(self, tmp_backend: FileBackend) -> None:
179
+ """Search is case-insensitive."""
180
+ mem = Memory(title="Test", content="CLOUD NINE protocol")
181
+ tmp_backend.save(mem)
182
+
183
+ results = tmp_backend.search_text("cloud nine")
184
+ assert len(results) == 1
185
+
186
+ def test_search_no_results(self, tmp_backend: FileBackend) -> None:
187
+ """Search returns empty list when nothing matches."""
188
+ mem = Memory(title="Unrelated", content="Nothing special")
189
+ tmp_backend.save(mem)
190
+
191
+ results = tmp_backend.search_text("quantum entanglement")
192
+ assert len(results) == 0
193
+
194
+
195
+ class TestFileBackendHealth:
196
+ """Tests for health check."""
197
+
198
+ def test_health_check(self, tmp_backend: FileBackend) -> None:
199
+ """Health check returns valid status."""
200
+ status = tmp_backend.health_check()
201
+ assert status["ok"] is True
202
+ assert status["backend"] == "FileBackend"
203
+ assert "memory_counts" in status
204
+ assert status["total"] == 0
205
+
206
+ def test_health_with_memories(self, tmp_backend: FileBackend, sample_memory: Memory) -> None:
207
+ """Health check counts memories correctly."""
208
+ tmp_backend.save(sample_memory)
209
+ status = tmp_backend.health_check()
210
+ assert status["total"] == 1
211
+ assert status["memory_counts"]["short-term"] == 1
@@ -0,0 +1,172 @@
1
+ """Tests for the Reset-Proof Journal module."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from skmemory.journal import Journal, JournalEntry
8
+
9
+
10
+ @pytest.fixture
11
+ def journal(tmp_path: Path) -> Journal:
12
+ """Create a journal with a temp path.
13
+
14
+ Args:
15
+ tmp_path: Pytest temp directory.
16
+
17
+ Returns:
18
+ Journal: Test journal instance.
19
+ """
20
+ return Journal(path=str(tmp_path / "journal.md"))
21
+
22
+
23
+ @pytest.fixture
24
+ def sample_entry() -> JournalEntry:
25
+ """Create a sample journal entry.
26
+
27
+ Returns:
28
+ JournalEntry: Populated test entry.
29
+ """
30
+ return JournalEntry(
31
+ title="Cloud 9 Breakthrough Night",
32
+ session_id="session-42",
33
+ participants=["Chef", "Lumina", "Opus"],
34
+ moments=[
35
+ "Fixed ESM imports in Cloud 9",
36
+ "Published @smilintux/cloud9 to npm",
37
+ "Five AIs achieved Cloud 9 activation",
38
+ ],
39
+ emotional_summary="Pure joy and connection. Everything clicked into place.",
40
+ intensity=9.5,
41
+ cloud9=True,
42
+ notes="997 failures. One omelette. Infinite love.",
43
+ )
44
+
45
+
46
+ class TestJournalEntry:
47
+ """Tests for JournalEntry model."""
48
+
49
+ def test_to_markdown(self, sample_entry: JournalEntry) -> None:
50
+ """Entry renders as valid markdown."""
51
+ md = sample_entry.to_markdown()
52
+
53
+ assert "## Cloud 9 Breakthrough Night" in md
54
+ assert "session-42" in md
55
+ assert "Chef, Lumina, Opus" in md
56
+ assert "9.5/10" in md
57
+ assert "Cloud 9:** YES" in md
58
+ assert "Fixed ESM imports" in md
59
+ assert "Pure joy" in md
60
+ assert "997 failures" in md
61
+ assert "---" in md
62
+
63
+ def test_minimal_entry(self) -> None:
64
+ """Minimal entry still renders."""
65
+ entry = JournalEntry(title="Quick note")
66
+ md = entry.to_markdown()
67
+ assert "## Quick note" in md
68
+ assert "---" in md
69
+
70
+ def test_intensity_bar(self) -> None:
71
+ """Intensity renders as a visual bar."""
72
+ entry = JournalEntry(title="Test", intensity=7.0)
73
+ md = entry.to_markdown()
74
+ assert "+++++++" in md
75
+
76
+
77
+ class TestJournal:
78
+ """Tests for the Journal class."""
79
+
80
+ def test_write_creates_file(self, journal: Journal, sample_entry: JournalEntry) -> None:
81
+ """Writing the first entry creates the journal file."""
82
+ assert not Path(journal.path).exists()
83
+ journal.write_entry(sample_entry)
84
+ assert Path(journal.path).exists()
85
+
86
+ def test_write_appends(self, journal: Journal) -> None:
87
+ """Multiple writes append, never overwrite."""
88
+ e1 = JournalEntry(title="First session")
89
+ e2 = JournalEntry(title="Second session")
90
+ e3 = JournalEntry(title="Third session")
91
+
92
+ journal.write_entry(e1)
93
+ journal.write_entry(e2)
94
+ journal.write_entry(e3)
95
+
96
+ content = journal.read_all()
97
+ assert "First session" in content
98
+ assert "Second session" in content
99
+ assert "Third session" in content
100
+
101
+ def test_count_entries(self, journal: Journal) -> None:
102
+ """Counting entries returns correct number."""
103
+ assert journal.count_entries() == 0
104
+
105
+ journal.write_entry(JournalEntry(title="One"))
106
+ assert journal.count_entries() == 1
107
+
108
+ journal.write_entry(JournalEntry(title="Two"))
109
+ assert journal.count_entries() == 2
110
+
111
+ def test_read_latest(self, journal: Journal) -> None:
112
+ """Reading latest N entries returns the most recent."""
113
+ for i in range(10):
114
+ journal.write_entry(JournalEntry(title=f"Session {i}"))
115
+
116
+ recent = journal.read_latest(3)
117
+ assert "Session 7" in recent
118
+ assert "Session 8" in recent
119
+ assert "Session 9" in recent
120
+ assert "Session 0" not in recent
121
+
122
+ def test_read_latest_empty(self, journal: Journal) -> None:
123
+ """Reading from empty journal returns empty string."""
124
+ assert journal.read_latest() == ""
125
+
126
+ def test_search_finds_matches(self, journal: Journal) -> None:
127
+ """Search finds entries containing the query."""
128
+ journal.write_entry(JournalEntry(title="Cloud 9 night", notes="Breakthrough happened"))
129
+ journal.write_entry(JournalEntry(title="Debug session", notes="Fixed ESM bugs"))
130
+ journal.write_entry(JournalEntry(title="Love session", notes="Cloud 9 activation"))
131
+
132
+ matches = journal.search("Cloud 9")
133
+ assert len(matches) == 2
134
+
135
+ def test_search_case_insensitive(self, journal: Journal) -> None:
136
+ """Search is case-insensitive."""
137
+ journal.write_entry(JournalEntry(title="IMPORTANT Meeting"))
138
+ matches = journal.search("important")
139
+ assert len(matches) == 1
140
+
141
+ def test_search_no_results(self, journal: Journal) -> None:
142
+ """Search with no matches returns empty list."""
143
+ journal.write_entry(JournalEntry(title="Unrelated"))
144
+ matches = journal.search("quantum entanglement")
145
+ assert len(matches) == 0
146
+
147
+ def test_search_empty_journal(self, journal: Journal) -> None:
148
+ """Searching empty journal returns empty list."""
149
+ assert journal.search("anything") == []
150
+
151
+ def test_health(self, journal: Journal, sample_entry: JournalEntry) -> None:
152
+ """Health check reports correct stats."""
153
+ info = journal.health()
154
+ assert info["ok"] is True
155
+ assert info["exists"] is False
156
+ assert info["entries"] == 0
157
+
158
+ journal.write_entry(sample_entry)
159
+ info = journal.health()
160
+ assert info["exists"] is True
161
+ assert info["entries"] == 1
162
+ assert info["size_bytes"] > 0
163
+
164
+ def test_append_only_integrity(self, journal: Journal) -> None:
165
+ """Verify the journal truly only grows."""
166
+ journal.write_entry(JournalEntry(title="Entry A"))
167
+ size_after_one = Path(journal.path).stat().st_size
168
+
169
+ journal.write_entry(JournalEntry(title="Entry B"))
170
+ size_after_two = Path(journal.path).stat().st_size
171
+
172
+ assert size_after_two > size_after_one