@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,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
|