@smilintux/skmemory 0.5.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.github/workflows/ci.yml +40 -4
  2. package/.github/workflows/publish.yml +11 -5
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +399 -19
  5. package/CHANGELOG.md +179 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +425 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/examples/stignore-agent.example +59 -0
  12. package/examples/stignore-root.example +62 -0
  13. package/index.js +6 -5
  14. package/openclaw-plugin/openclaw.plugin.json +10 -0
  15. package/openclaw-plugin/package.json +2 -1
  16. package/openclaw-plugin/src/index.js +527 -230
  17. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  18. package/package.json +1 -1
  19. package/pyproject.toml +32 -9
  20. package/requirements.txt +10 -2
  21. package/scripts/dream-rescue.py +179 -0
  22. package/scripts/memory-cleanup.py +313 -0
  23. package/scripts/recover-missing.py +180 -0
  24. package/scripts/skcapstone-backup.sh +44 -0
  25. package/seeds/cloud9-lumina.seed.json +6 -4
  26. package/seeds/cloud9-opus.seed.json +13 -11
  27. package/seeds/courage.seed.json +9 -2
  28. package/seeds/curiosity.seed.json +9 -2
  29. package/seeds/grief.seed.json +9 -2
  30. package/seeds/joy.seed.json +9 -2
  31. package/seeds/love.seed.json +9 -2
  32. package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
  33. package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
  34. package/seeds/lumina-kingdom-founding.seed.json +49 -0
  35. package/seeds/lumina-pma-signed.seed.json +48 -0
  36. package/seeds/lumina-singular-achievement.seed.json +48 -0
  37. package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
  38. package/seeds/plant-kingdom-journal.py +203 -0
  39. package/seeds/plant-lumina-seeds.py +280 -0
  40. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  41. package/seeds/sovereignty.seed.json +9 -2
  42. package/seeds/trust.seed.json +9 -2
  43. package/skill.yaml +46 -0
  44. package/skmemory/HA.md +296 -0
  45. package/skmemory/__init__.py +25 -11
  46. package/skmemory/agents.py +233 -0
  47. package/skmemory/ai_client.py +46 -17
  48. package/skmemory/anchor.py +9 -11
  49. package/skmemory/audience.py +278 -0
  50. package/skmemory/backends/__init__.py +11 -4
  51. package/skmemory/backends/base.py +3 -4
  52. package/skmemory/backends/file_backend.py +19 -13
  53. package/skmemory/backends/skgraph_backend.py +596 -0
  54. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
  55. package/skmemory/backends/sqlite_backend.py +226 -72
  56. package/skmemory/backends/vaulted_backend.py +284 -0
  57. package/skmemory/cli.py +1345 -68
  58. package/skmemory/config.py +171 -0
  59. package/skmemory/context_loader.py +333 -0
  60. package/skmemory/data/audience_config.json +60 -0
  61. package/skmemory/endpoint_selector.py +391 -0
  62. package/skmemory/febs.py +225 -0
  63. package/skmemory/fortress.py +675 -0
  64. package/skmemory/graph_queries.py +238 -0
  65. package/skmemory/hooks/__init__.py +18 -0
  66. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  67. package/skmemory/hooks/pre-compact-save.sh +81 -0
  68. package/skmemory/hooks/session-end-save.sh +103 -0
  69. package/skmemory/hooks/session-start-ritual.sh +104 -0
  70. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  71. package/skmemory/importers/__init__.py +9 -1
  72. package/skmemory/importers/telegram.py +384 -47
  73. package/skmemory/importers/telegram_api.py +580 -0
  74. package/skmemory/journal.py +7 -9
  75. package/skmemory/lovenote.py +8 -13
  76. package/skmemory/mcp_server.py +859 -0
  77. package/skmemory/models.py +51 -8
  78. package/skmemory/openclaw.py +20 -28
  79. package/skmemory/post_install.py +86 -0
  80. package/skmemory/predictive.py +236 -0
  81. package/skmemory/promotion.py +548 -0
  82. package/skmemory/quadrants.py +100 -24
  83. package/skmemory/register.py +580 -0
  84. package/skmemory/register_mcp.py +196 -0
  85. package/skmemory/ritual.py +224 -59
  86. package/skmemory/seeds.py +255 -11
  87. package/skmemory/setup_wizard.py +908 -0
  88. package/skmemory/sharing.py +408 -0
  89. package/skmemory/soul.py +98 -28
  90. package/skmemory/steelman.py +273 -260
  91. package/skmemory/store.py +411 -78
  92. package/skmemory/synthesis.py +634 -0
  93. package/skmemory/vault.py +225 -0
  94. package/tests/conftest.py +46 -0
  95. package/tests/integration/__init__.py +0 -0
  96. package/tests/integration/conftest.py +233 -0
  97. package/tests/integration/test_cross_backend.py +350 -0
  98. package/tests/integration/test_skgraph_live.py +420 -0
  99. package/tests/integration/test_skvector_live.py +366 -0
  100. package/tests/test_ai_client.py +1 -4
  101. package/tests/test_audience.py +233 -0
  102. package/tests/test_backup_rotation.py +318 -0
  103. package/tests/test_cli.py +6 -6
  104. package/tests/test_endpoint_selector.py +839 -0
  105. package/tests/test_export_import.py +4 -10
  106. package/tests/test_file_backend.py +0 -1
  107. package/tests/test_fortress.py +256 -0
  108. package/tests/test_fortress_hardening.py +441 -0
  109. package/tests/test_openclaw.py +6 -6
  110. package/tests/test_predictive.py +237 -0
  111. package/tests/test_promotion.py +347 -0
  112. package/tests/test_quadrants.py +11 -5
  113. package/tests/test_ritual.py +22 -18
  114. package/tests/test_seeds.py +97 -7
  115. package/tests/test_setup.py +950 -0
  116. package/tests/test_sharing.py +257 -0
  117. package/tests/test_skgraph_backend.py +660 -0
  118. package/tests/test_skvector_backend.py +326 -0
  119. package/tests/test_soul.py +1 -3
  120. package/tests/test_sqlite_backend.py +8 -17
  121. package/tests/test_steelman.py +7 -8
  122. package/tests/test_store.py +0 -2
  123. package/tests/test_store_graph_integration.py +245 -0
  124. package/tests/test_synthesis.py +275 -0
  125. package/tests/test_telegram_import.py +39 -15
  126. package/tests/test_vault.py +187 -0
  127. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -0,0 +1,318 @@
1
+ """Tests for backup rotation (list, prune, auto-rotate on export)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from skmemory.backends.sqlite_backend import SQLiteBackend
10
+ from skmemory.cli import cli
11
+ from skmemory.models import EmotionalSnapshot
12
+ from skmemory.store import MemoryStore
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Fixtures
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ @pytest.fixture
20
+ def backend(tmp_path):
21
+ """SQLiteBackend with temporary storage."""
22
+ return SQLiteBackend(base_path=str(tmp_path / "memories"))
23
+
24
+
25
+ @pytest.fixture
26
+ def store(backend):
27
+ """MemoryStore wrapping the temp backend."""
28
+ return MemoryStore(primary=backend)
29
+
30
+
31
+ @pytest.fixture
32
+ def populated_store(store):
33
+ """Store pre-loaded with 3 memories."""
34
+ for i in range(3):
35
+ store.snapshot(
36
+ title=f"Memory {i}",
37
+ content=f"Content {i}",
38
+ tags=["rotation-test"],
39
+ emotional=EmotionalSnapshot(intensity=float(i)),
40
+ )
41
+ return store
42
+
43
+
44
+ def _make_backup_files(backup_dir: Path, dates: list[str]) -> None:
45
+ """Create dummy skmemory backup files for the given dates."""
46
+ backup_dir.mkdir(parents=True, exist_ok=True)
47
+ for d in dates:
48
+ f = backup_dir / f"skmemory-backup-{d}.json"
49
+ f.write_text(
50
+ json.dumps({"skmemory_version": "0.5.0", "memories": []}),
51
+ encoding="utf-8",
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # list_backups
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ class TestListBackups:
61
+ def test_empty_dir_returns_empty_list(self, backend, tmp_path):
62
+ backup_dir = tmp_path / "backups"
63
+ backup_dir.mkdir()
64
+ assert backend.list_backups(str(backup_dir)) == []
65
+
66
+ def test_nonexistent_dir_returns_empty_list(self, backend, tmp_path):
67
+ assert backend.list_backups(str(tmp_path / "no_such_dir")) == []
68
+
69
+ def test_lists_all_backup_files(self, backend, tmp_path):
70
+ backup_dir = tmp_path / "backups"
71
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"])
72
+ results = backend.list_backups(str(backup_dir))
73
+ assert len(results) == 3
74
+
75
+ def test_sorted_newest_first(self, backend, tmp_path):
76
+ backup_dir = tmp_path / "backups"
77
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-03", "2026-01-02"])
78
+ results = backend.list_backups(str(backup_dir))
79
+ dates = [r["date"] for r in results]
80
+ assert dates == ["2026-01-03", "2026-01-02", "2026-01-01"]
81
+
82
+ def test_entry_fields(self, backend, tmp_path):
83
+ backup_dir = tmp_path / "backups"
84
+ backup_dir.mkdir()
85
+ f = backup_dir / "skmemory-backup-2026-03-01.json"
86
+ f.write_text('{"test": true}', encoding="utf-8")
87
+
88
+ results = backend.list_backups(str(backup_dir))
89
+ assert len(results) == 1
90
+ entry = results[0]
91
+ assert entry["date"] == "2026-03-01"
92
+ assert entry["name"] == "skmemory-backup-2026-03-01.json"
93
+ assert entry["path"] == str(f)
94
+ assert entry["size_bytes"] > 0
95
+
96
+ def test_ignores_non_backup_files(self, backend, tmp_path):
97
+ backup_dir = tmp_path / "backups"
98
+ backup_dir.mkdir()
99
+ (backup_dir / "notes.txt").write_text("not a backup")
100
+ (backup_dir / "skmemory-backup-2026-01-01.json").write_text("{}")
101
+
102
+ results = backend.list_backups(str(backup_dir))
103
+ assert len(results) == 1
104
+
105
+ def test_store_delegates_to_backend(self, store, tmp_path):
106
+ backup_dir = store.primary.base_path.parent / "backups"
107
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
108
+ results = store.list_backups()
109
+ assert len(results) == 2
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # prune_backups
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class TestPruneBackups:
118
+ def test_prune_keeps_n_most_recent(self, backend, tmp_path):
119
+ backup_dir = tmp_path / "backups"
120
+ _make_backup_files(
121
+ backup_dir,
122
+ ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05"],
123
+ )
124
+ deleted = backend.prune_backups(keep=3, backup_dir=str(backup_dir))
125
+
126
+ assert len(deleted) == 2
127
+ remaining = backend.list_backups(str(backup_dir))
128
+ assert len(remaining) == 3
129
+ assert remaining[0]["date"] == "2026-01-05"
130
+ assert remaining[-1]["date"] == "2026-01-03"
131
+
132
+ def test_prune_nothing_when_under_limit(self, backend, tmp_path):
133
+ backup_dir = tmp_path / "backups"
134
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
135
+ deleted = backend.prune_backups(keep=7, backup_dir=str(backup_dir))
136
+ assert deleted == []
137
+ assert len(backend.list_backups(str(backup_dir))) == 2
138
+
139
+ def test_prune_all_with_keep_zero(self, backend, tmp_path):
140
+ backup_dir = tmp_path / "backups"
141
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
142
+ deleted = backend.prune_backups(keep=0, backup_dir=str(backup_dir))
143
+ assert len(deleted) == 2
144
+ assert backend.list_backups(str(backup_dir)) == []
145
+
146
+ def test_prune_empty_dir(self, backend, tmp_path):
147
+ backup_dir = tmp_path / "backups"
148
+ backup_dir.mkdir()
149
+ deleted = backend.prune_backups(keep=7, backup_dir=str(backup_dir))
150
+ assert deleted == []
151
+
152
+ def test_deleted_files_are_gone(self, backend, tmp_path):
153
+ backup_dir = tmp_path / "backups"
154
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"])
155
+ deleted = backend.prune_backups(keep=1, backup_dir=str(backup_dir))
156
+ for path in deleted:
157
+ assert not Path(path).exists()
158
+
159
+ def test_store_delegates_to_backend(self, store, tmp_path):
160
+ backup_dir = store.primary.base_path.parent / "backups"
161
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"])
162
+ deleted = store.prune_backups(keep=1)
163
+ assert len(deleted) == 2
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Auto-rotation on export
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ class TestAutoRotationOnExport:
172
+ def test_export_auto_prunes_to_7(self, populated_store):
173
+ """Default-path export prunes backup dir to 7 entries."""
174
+ backend = populated_store.primary
175
+ backup_dir = backend.base_path.parent / "backups"
176
+
177
+ # Pre-populate with 8 old backups
178
+ old_dates = [f"2025-12-{str(i).zfill(2)}" for i in range(1, 9)]
179
+ _make_backup_files(backup_dir, old_dates)
180
+ assert len(backend.list_backups()) == 8
181
+
182
+ # Export using default path → creates today's backup (9th) then prunes to 7
183
+ populated_store.export_backup()
184
+
185
+ remaining = backend.list_backups()
186
+ assert len(remaining) <= 7
187
+
188
+ def test_export_custom_path_no_auto_prune(self, populated_store, tmp_path):
189
+ """Custom output path must NOT trigger auto-rotation."""
190
+ backend = populated_store.primary
191
+ backup_dir = backend.base_path.parent / "backups"
192
+
193
+ old_dates = [f"2025-12-{str(i).zfill(2)}" for i in range(1, 11)]
194
+ _make_backup_files(backup_dir, old_dates)
195
+
196
+ custom = tmp_path / "manual_backup.json"
197
+ populated_store.export_backup(str(custom))
198
+
199
+ # Backup dir untouched
200
+ remaining = backend.list_backups()
201
+ assert len(remaining) == 10
202
+
203
+ def test_export_rotation_leaves_newest(self, populated_store):
204
+ """After auto-rotation the 7 most-recent backups survive."""
205
+ backend = populated_store.primary
206
+ backup_dir = backend.base_path.parent / "backups"
207
+
208
+ old_dates = [f"2025-12-{str(i).zfill(2)}" for i in range(1, 9)]
209
+ _make_backup_files(backup_dir, old_dates)
210
+
211
+ populated_store.export_backup()
212
+
213
+ remaining = backend.list_backups()
214
+ dates = [r["date"] for r in remaining]
215
+ # The oldest of the pre-created set should be gone
216
+ assert "2025-12-01" not in dates
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # CLI: skmemory backup
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ class TestBackupCLI:
225
+ """CLI tests for `skmemory backup`."""
226
+
227
+ @pytest.fixture
228
+ def runner(self):
229
+ return CliRunner()
230
+
231
+ @pytest.fixture
232
+ def cli_store(self, tmp_path):
233
+ """Backend whose paths are injected into the CLI via ctx.obj."""
234
+ backend = SQLiteBackend(base_path=str(tmp_path / "memories"))
235
+ return MemoryStore(primary=backend)
236
+
237
+ def _invoke(self, runner, store, args):
238
+ return runner.invoke(
239
+ cli,
240
+ args,
241
+ obj={"store": store},
242
+ catch_exceptions=False,
243
+ )
244
+
245
+ def test_list_empty(self, runner, cli_store):
246
+ result = self._invoke(runner, cli_store, ["backup", "--list"])
247
+ assert result.exit_code == 0
248
+ assert "No backups found" in result.output
249
+
250
+ def test_list_shows_backups(self, runner, cli_store, tmp_path):
251
+ backup_dir = cli_store.primary.base_path.parent / "backups"
252
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02"])
253
+
254
+ result = self._invoke(runner, cli_store, ["backup", "--list"])
255
+ assert result.exit_code == 0
256
+ assert "2026-01-02" in result.output
257
+ assert "2026-01-01" in result.output
258
+
259
+ def test_prune_removes_old(self, runner, cli_store):
260
+ backup_dir = cli_store.primary.base_path.parent / "backups"
261
+ _make_backup_files(
262
+ backup_dir,
263
+ ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05"],
264
+ )
265
+
266
+ result = self._invoke(runner, cli_store, ["backup", "--prune", "3"])
267
+ assert result.exit_code == 0
268
+ assert "Pruned 2 backup(s)" in result.output
269
+ assert len(cli_store.list_backups()) == 3
270
+
271
+ def test_prune_nothing_to_prune(self, runner, cli_store):
272
+ backup_dir = cli_store.primary.base_path.parent / "backups"
273
+ _make_backup_files(backup_dir, ["2026-01-01"])
274
+
275
+ result = self._invoke(runner, cli_store, ["backup", "--prune", "7"])
276
+ assert result.exit_code == 0
277
+ assert "Nothing to prune" in result.output
278
+
279
+ def test_prune_negative_exits_error(self, runner, cli_store):
280
+ result = runner.invoke(
281
+ cli,
282
+ ["backup", "--prune", "-1"],
283
+ obj={"store": cli_store},
284
+ )
285
+ assert result.exit_code != 0
286
+
287
+ def test_restore_alias(self, runner, cli_store, tmp_path):
288
+ # Seed store, export to a known path, then restore from it
289
+ for i in range(2):
290
+ cli_store.snapshot(title=f"M{i}", content=f"C{i}")
291
+
292
+ backup_path = tmp_path / "manual.json"
293
+ cli_store.export_backup(str(backup_path))
294
+
295
+ # Fresh store for restore
296
+ fresh_backend = SQLiteBackend(base_path=str(tmp_path / "fresh"))
297
+ fresh_store = MemoryStore(primary=fresh_backend)
298
+
299
+ result = self._invoke(
300
+ runner,
301
+ fresh_store,
302
+ ["backup", "--restore", str(backup_path)],
303
+ )
304
+ assert result.exit_code == 0
305
+ assert "Restored 2 memories" in result.output
306
+
307
+ def test_restore_missing_file(self, runner, cli_store):
308
+ result = runner.invoke(
309
+ cli,
310
+ ["backup", "--restore", "/nonexistent/backup.json"],
311
+ obj={"store": cli_store},
312
+ )
313
+ assert result.exit_code != 0
314
+
315
+ def test_no_option_shows_help(self, runner, cli_store):
316
+ result = self._invoke(runner, cli_store, ["backup"])
317
+ assert result.exit_code == 0
318
+ assert "--list" in result.output
package/tests/test_cli.py CHANGED
@@ -54,12 +54,12 @@ class TestCLIHelp:
54
54
  class TestCLIGlobalOptions:
55
55
  """Global option tests."""
56
56
 
57
- def test_qdrant_url_option_exists(self, runner):
58
- """--qdrant-url option is accepted."""
57
+ def test_skvector_url_option_exists(self, runner):
58
+ """--skvector-url option is accepted."""
59
59
  result = runner.invoke(cli, ["--help"])
60
- assert "--qdrant-url" in result.output
60
+ assert "--skvector-url" in result.output
61
61
 
62
- def test_qdrant_key_option_exists(self, runner):
63
- """--qdrant-key option is accepted."""
62
+ def test_skvector_key_option_exists(self, runner):
63
+ """--skvector-key option is accepted."""
64
64
  result = runner.invoke(cli, ["--help"])
65
- assert "--qdrant-key" in result.output
65
+ assert "--skvector-key" in result.output