@smilintux/skmemory 0.7.2 → 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 (111) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/.github/workflows/publish.yml +4 -5
  3. package/ARCHITECTURE.md +298 -0
  4. package/CHANGELOG.md +27 -1
  5. package/README.md +6 -0
  6. package/examples/stignore-agent.example +59 -0
  7. package/examples/stignore-root.example +62 -0
  8. package/openclaw-plugin/package.json +2 -1
  9. package/openclaw-plugin/src/index.js +527 -230
  10. package/package.json +1 -1
  11. package/pyproject.toml +5 -2
  12. package/scripts/dream-rescue.py +179 -0
  13. package/scripts/memory-cleanup.py +313 -0
  14. package/scripts/recover-missing.py +180 -0
  15. package/scripts/skcapstone-backup.sh +44 -0
  16. package/seeds/cloud9-lumina.seed.json +6 -4
  17. package/seeds/cloud9-opus.seed.json +6 -4
  18. package/seeds/courage.seed.json +9 -2
  19. package/seeds/curiosity.seed.json +9 -2
  20. package/seeds/grief.seed.json +9 -2
  21. package/seeds/joy.seed.json +9 -2
  22. package/seeds/love.seed.json +9 -2
  23. package/seeds/lumina-cloud9-breakthrough.seed.json +7 -5
  24. package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
  25. package/seeds/lumina-kingdom-founding.seed.json +9 -7
  26. package/seeds/lumina-pma-signed.seed.json +8 -6
  27. package/seeds/lumina-singular-achievement.seed.json +8 -6
  28. package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
  29. package/seeds/plant-lumina-seeds.py +2 -2
  30. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  31. package/seeds/sovereignty.seed.json +9 -2
  32. package/seeds/trust.seed.json +9 -2
  33. package/skmemory/__init__.py +16 -13
  34. package/skmemory/agents.py +10 -10
  35. package/skmemory/ai_client.py +10 -21
  36. package/skmemory/anchor.py +5 -9
  37. package/skmemory/audience.py +278 -0
  38. package/skmemory/backends/__init__.py +1 -1
  39. package/skmemory/backends/base.py +3 -4
  40. package/skmemory/backends/file_backend.py +18 -13
  41. package/skmemory/backends/skgraph_backend.py +7 -19
  42. package/skmemory/backends/skvector_backend.py +7 -18
  43. package/skmemory/backends/sqlite_backend.py +115 -32
  44. package/skmemory/backends/vaulted_backend.py +7 -9
  45. package/skmemory/cli.py +146 -78
  46. package/skmemory/config.py +11 -13
  47. package/skmemory/context_loader.py +21 -23
  48. package/skmemory/data/audience_config.json +60 -0
  49. package/skmemory/endpoint_selector.py +36 -31
  50. package/skmemory/febs.py +225 -0
  51. package/skmemory/fortress.py +30 -40
  52. package/skmemory/hooks/__init__.py +18 -0
  53. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  54. package/skmemory/hooks/pre-compact-save.sh +81 -0
  55. package/skmemory/hooks/session-end-save.sh +103 -0
  56. package/skmemory/hooks/session-start-ritual.sh +104 -0
  57. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  58. package/skmemory/importers/telegram.py +42 -13
  59. package/skmemory/importers/telegram_api.py +152 -60
  60. package/skmemory/journal.py +3 -7
  61. package/skmemory/lovenote.py +4 -11
  62. package/skmemory/mcp_server.py +182 -29
  63. package/skmemory/models.py +10 -8
  64. package/skmemory/openclaw.py +14 -22
  65. package/skmemory/post_install.py +86 -0
  66. package/skmemory/predictive.py +13 -9
  67. package/skmemory/promotion.py +48 -24
  68. package/skmemory/quadrants.py +100 -24
  69. package/skmemory/register.py +144 -18
  70. package/skmemory/register_mcp.py +1 -2
  71. package/skmemory/ritual.py +104 -13
  72. package/skmemory/seeds.py +21 -26
  73. package/skmemory/setup_wizard.py +40 -52
  74. package/skmemory/sharing.py +11 -5
  75. package/skmemory/soul.py +29 -10
  76. package/skmemory/steelman.py +43 -17
  77. package/skmemory/store.py +152 -30
  78. package/skmemory/synthesis.py +634 -0
  79. package/skmemory/vault.py +2 -5
  80. package/tests/conftest.py +46 -0
  81. package/tests/integration/conftest.py +6 -6
  82. package/tests/integration/test_cross_backend.py +4 -9
  83. package/tests/integration/test_skgraph_live.py +3 -7
  84. package/tests/integration/test_skvector_live.py +1 -4
  85. package/tests/test_ai_client.py +1 -4
  86. package/tests/test_audience.py +233 -0
  87. package/tests/test_backup_rotation.py +5 -14
  88. package/tests/test_endpoint_selector.py +101 -63
  89. package/tests/test_export_import.py +4 -10
  90. package/tests/test_file_backend.py +0 -1
  91. package/tests/test_fortress.py +6 -5
  92. package/tests/test_fortress_hardening.py +13 -16
  93. package/tests/test_openclaw.py +1 -4
  94. package/tests/test_predictive.py +1 -1
  95. package/tests/test_promotion.py +10 -3
  96. package/tests/test_quadrants.py +11 -5
  97. package/tests/test_ritual.py +18 -14
  98. package/tests/test_seeds.py +4 -10
  99. package/tests/test_setup.py +203 -88
  100. package/tests/test_sharing.py +15 -8
  101. package/tests/test_skgraph_backend.py +22 -29
  102. package/tests/test_skvector_backend.py +2 -2
  103. package/tests/test_soul.py +1 -3
  104. package/tests/test_sqlite_backend.py +8 -17
  105. package/tests/test_steelman.py +2 -3
  106. package/tests/test_store.py +0 -2
  107. package/tests/test_store_graph_integration.py +2 -2
  108. package/tests/test_synthesis.py +275 -0
  109. package/tests/test_telegram_import.py +39 -15
  110. package/tests/test_vault.py +4 -3
  111. package/openclaw-plugin/src/index.ts +0 -255
@@ -0,0 +1,46 @@
1
+ """
2
+ Root conftest.py — bootstraps a minimal test agent before any skmemory module
3
+ is imported by pytest.
4
+
5
+ Several skmemory modules (seeds.py, febs.py, soul.py, etc.) call
6
+ ``get_agent_paths()`` at **module level**, which raises ``ValueError`` if no
7
+ agent directory exists under ``~/.skcapstone/agents/``. This file runs before
8
+ pytest collects any test files, so setting ``SKCAPSTONE_HOME`` and
9
+ ``SKCAPSTONE_AGENT`` here — and creating the matching directory tree — ensures
10
+ those imports always succeed in CI and other fresh environments.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ # ── Bootstrap a throw-away test agent ─────────────────────────────────────────
20
+ # Must happen at module level (not inside a fixture) so it takes effect before
21
+ # pytest imports test files that themselves import skmemory.
22
+
23
+ _TEST_AGENT_NAME = "test-agent"
24
+
25
+ # Use a temp dir so we never pollute the real ~/.skcapstone tree.
26
+ _tmp_skcapstone = tempfile.mkdtemp(prefix="skmemory_ci_")
27
+ os.environ["SKCAPSTONE_HOME"] = _tmp_skcapstone
28
+ os.environ["SKCAPSTONE_AGENT"] = _TEST_AGENT_NAME
29
+
30
+ _agent_base = Path(_tmp_skcapstone) / "agents" / _TEST_AGENT_NAME
31
+ for _subdir in (
32
+ "config",
33
+ "seeds",
34
+ "memory/short-term",
35
+ "memory/mid-term",
36
+ "memory/long-term",
37
+ "logs",
38
+ "archive",
39
+ ):
40
+ (_agent_base / _subdir).mkdir(parents=True, exist_ok=True)
41
+
42
+ # Minimal config so list_agents() finds this agent and load_config() doesn't
43
+ # blow up.
44
+ (_agent_base / "config" / "skmemory.yaml").write_text(
45
+ "# Auto-generated by tests/conftest.py\nname: test-agent\n"
46
+ )
@@ -13,6 +13,7 @@ never touched. Both are torn down after the test session.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import contextlib
16
17
  import os
17
18
  import uuid
18
19
 
@@ -70,6 +71,7 @@ def _skvector_available() -> bool:
70
71
  def _sentence_transformers_available() -> bool:
71
72
  try:
72
73
  import sentence_transformers # noqa: F401 # type: ignore[import]
74
+
73
75
  return True
74
76
  except ImportError:
75
77
  return False
@@ -126,6 +128,7 @@ def falkordb_backend():
126
128
  # Teardown: drop the test graph
127
129
  try:
128
130
  from falkordb import FalkorDB # type: ignore[import]
131
+
129
132
  db = FalkorDB.from_url(SKGRAPH_URL)
130
133
  db.select_graph(TEST_GRAPH_NAME).delete()
131
134
  except Exception:
@@ -136,10 +139,8 @@ def falkordb_backend():
136
139
  def falkordb_clean(falkordb_backend):
137
140
  """SKGraphBackend with test graph wiped before each test."""
138
141
  # Clear all nodes so tests are independent
139
- try:
142
+ with contextlib.suppress(Exception):
140
143
  falkordb_backend._graph.query("MATCH (n) DETACH DELETE n")
141
- except Exception:
142
- pass
143
144
  return falkordb_backend
144
145
 
145
146
 
@@ -171,10 +172,8 @@ def qdrant_backend():
171
172
  yield backend
172
173
 
173
174
  # Teardown: delete test collection
174
- try:
175
+ with contextlib.suppress(Exception):
175
176
  backend._client.delete_collection(TEST_COLLECTION_NAME)
176
- except Exception:
177
- pass
178
177
 
179
178
 
180
179
  @pytest.fixture
@@ -182,6 +181,7 @@ def qdrant_clean(qdrant_backend):
182
181
  """SKVectorBackend with collection wiped before each test."""
183
182
  try:
184
183
  from qdrant_client.models import Distance, VectorParams
184
+
185
185
  from skmemory.backends.skvector_backend import VECTOR_DIM
186
186
 
187
187
  qdrant_backend._client.delete_collection(TEST_COLLECTION_NAME)
@@ -150,9 +150,7 @@ class TestTagConsistency:
150
150
 
151
151
  for tag in tags:
152
152
  fb_results = fb.search_by_tags([tag])
153
- assert any(r["id"] == mem.id for r in fb_results), (
154
- f"FalkorDB missing tag: {tag}"
155
- )
153
+ assert any(r["id"] == mem.id for r in fb_results), f"FalkorDB missing tag: {tag}"
156
154
 
157
155
  # Qdrant verifies tags are in the stored memory
158
156
  qdrant_memories = qd.list_memories(limit=100)
@@ -179,7 +177,7 @@ class TestPromotionLineageConsistency:
179
177
 
180
178
  # FalkorDB lineage
181
179
  lineage = fb.get_lineage(child.id)
182
- ancestor_ids = [l["id"] for l in lineage]
180
+ ancestor_ids = [lbl["id"] for lbl in lineage]
183
181
  assert parent.id in ancestor_ids, "FalkorDB lineage should include parent"
184
182
 
185
183
  # Qdrant: both parent and child indexed
@@ -191,9 +189,7 @@ class TestPromotionLineageConsistency:
191
189
  # Qdrant: child's parent_id preserved
192
190
  qdrant_child = next((m for m in qdrant_memories if m.id == child.id), None)
193
191
  assert qdrant_child is not None
194
- assert qdrant_child.parent_id == parent.id, (
195
- "Qdrant should preserve parent_id in payload"
196
- )
192
+ assert qdrant_child.parent_id == parent.id, "Qdrant should preserve parent_id in payload"
197
193
 
198
194
  def test_multi_hop_lineage_all_indexed_in_qdrant(self, backends):
199
195
  fb, qd = backends
@@ -206,7 +202,7 @@ class TestPromotionLineageConsistency:
206
202
  _dual_save(fb, qd, mem)
207
203
 
208
204
  lineage = fb.get_lineage(child.id)
209
- ancestor_ids = {l["id"] for l in lineage}
205
+ ancestor_ids = {lbl["id"] for lbl in lineage}
210
206
  assert parent.id in ancestor_ids
211
207
  assert grandparent.id in ancestor_ids
212
208
 
@@ -239,7 +235,6 @@ class TestSearchComplementarity:
239
235
 
240
236
  # Qdrant semantic search
241
237
  qd_results = qd.search_text("agent self-awareness continuity", limit=10)
242
- qd_ids = [m.id for m in qd_results]
243
238
  # Semantic search may not always return the exact memory but should not error
244
239
  assert isinstance(qd_results, list)
245
240
 
@@ -22,8 +22,6 @@ Coverage:
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
- import pytest
26
-
27
25
  from .conftest import make_memory, requires_skgraph
28
26
 
29
27
  pytestmark = requires_skgraph
@@ -152,7 +150,6 @@ class TestSKGraphEdges:
152
150
 
153
151
  def test_related_to_explicit_edges(self, falkordb_clean):
154
152
  mem_a = make_memory(title="Memory A")
155
- mem_b = make_memory(title="Memory B")
156
153
  falkordb_clean.save(mem_a)
157
154
  # mem_b has mem_a in related_ids
158
155
  mem_b_linked = make_memory(title="Memory B", related_ids=[mem_a.id])
@@ -172,7 +169,7 @@ class TestSKGraphEdges:
172
169
 
173
170
  lineage = falkordb_clean.get_lineage(child.id)
174
171
  assert len(lineage) >= 1
175
- ancestor_ids = [l["id"] for l in lineage]
172
+ ancestor_ids = [lbl["id"] for lbl in lineage]
176
173
  assert parent.id in ancestor_ids
177
174
 
178
175
  def test_auto_shared_tag_related_to(self, falkordb_clean):
@@ -337,7 +334,7 @@ class TestSKGraphTraversal:
337
334
  falkordb_clean.save(child)
338
335
 
339
336
  lineage = falkordb_clean.get_lineage(child.id)
340
- ancestor_ids = [l["id"] for l in lineage]
337
+ ancestor_ids = [lbl["id"] for lbl in lineage]
341
338
  assert parent.id in ancestor_ids
342
339
  assert grandparent.id in ancestor_ids
343
340
 
@@ -353,8 +350,7 @@ class TestSKGraphClusters:
353
350
  # Create a hub: mem_hub shares tags with many others
354
351
  hub = make_memory(title="Hub", tags=["hub-tag", "shared-tag"])
355
352
  spokes = [
356
- make_memory(title=f"Spoke {i}", tags=["hub-tag", "shared-tag"])
357
- for i in range(4)
353
+ make_memory(title=f"Spoke {i}", tags=["hub-tag", "shared-tag"]) for i in range(4)
358
354
  ]
359
355
  falkordb_clean.save(hub)
360
356
  for s in spokes:
@@ -20,10 +20,6 @@ Coverage:
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
- import time
24
-
25
- import pytest
26
-
27
23
  from .conftest import make_memory, requires_skvector
28
24
 
29
25
  pytestmark = requires_skvector
@@ -206,6 +202,7 @@ class TestSKVectorVectorSearch:
206
202
  # At minimum, no error is raised and results are Memory objects
207
203
  for m in results:
208
204
  from skmemory.models import Memory
205
+
209
206
  assert isinstance(m, Memory)
210
207
 
211
208
  def test_search_text_empty_collection_returns_empty(self, qdrant_clean):
@@ -4,12 +4,9 @@ These tests verify the client interface without requiring a running
4
4
  Ollama server. The client is designed to fail gracefully.
5
5
  """
6
6
 
7
- import json
8
- from unittest.mock import MagicMock, patch
9
-
10
7
  import pytest
11
8
 
12
- from skmemory.ai_client import AIClient, DEFAULT_MODEL, DEFAULT_URL
9
+ from skmemory.ai_client import DEFAULT_MODEL, DEFAULT_URL, AIClient
13
10
 
14
11
 
15
12
  class TestClientInit:
@@ -0,0 +1,233 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Tests for the Know Your Audience (KYA) audience filtering system."""
3
+
4
+ import json
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from skmemory.audience import AudienceLevel, AudienceProfile, AudienceResolver, tag_to_level
11
+
12
+
13
+ # ── AudienceLevel ordering ────────────────────────────────────────────────────
14
+
15
+
16
+ class TestAudienceLevel:
17
+ def test_ordering(self):
18
+ assert AudienceLevel.PUBLIC < AudienceLevel.COMMUNITY
19
+ assert AudienceLevel.COMMUNITY < AudienceLevel.WORK_CIRCLE
20
+ assert AudienceLevel.WORK_CIRCLE < AudienceLevel.INNER_CIRCLE
21
+ assert AudienceLevel.INNER_CIRCLE < AudienceLevel.CHEF_ONLY
22
+
23
+ def test_values(self):
24
+ assert AudienceLevel.PUBLIC == 0
25
+ assert AudienceLevel.CHEF_ONLY == 4
26
+
27
+ def test_comparison(self):
28
+ # Content at work-circle level should be allowed in chef-only audience
29
+ assert AudienceLevel.WORK_CIRCLE <= AudienceLevel.CHEF_ONLY
30
+ # Content at chef-only level should NOT be allowed in work-circle audience
31
+ assert not (AudienceLevel.CHEF_ONLY <= AudienceLevel.WORK_CIRCLE)
32
+
33
+
34
+ # ── tag_to_level ──────────────────────────────────────────────────────────────
35
+
36
+
37
+ class TestTagToLevel:
38
+ def test_exact_tags(self):
39
+ assert tag_to_level("@public") == AudienceLevel.PUBLIC
40
+ assert tag_to_level("@community") == AudienceLevel.COMMUNITY
41
+ assert tag_to_level("@work-circle") == AudienceLevel.WORK_CIRCLE
42
+ assert tag_to_level("@inner-circle") == AudienceLevel.INNER_CIRCLE
43
+ assert tag_to_level("@chef-only") == AudienceLevel.CHEF_ONLY
44
+
45
+ def test_scoped_work_tags(self):
46
+ assert tag_to_level("@work:chiro") == AudienceLevel.WORK_CIRCLE
47
+ assert tag_to_level("@work:swapseat") == AudienceLevel.WORK_CIRCLE
48
+ assert tag_to_level("@work:sovereign") == AudienceLevel.WORK_CIRCLE
49
+ assert tag_to_level("@work:gentis") == AudienceLevel.WORK_CIRCLE
50
+
51
+ def test_scoped_inner_tags(self):
52
+ assert tag_to_level("@inner:family") == AudienceLevel.INNER_CIRCLE
53
+
54
+ def test_unknown_defaults_to_chef_only(self):
55
+ assert tag_to_level("@unknown") == AudienceLevel.CHEF_ONLY
56
+ assert tag_to_level("random-string") == AudienceLevel.CHEF_ONLY
57
+
58
+ def test_empty_defaults_to_chef_only(self):
59
+ assert tag_to_level("") == AudienceLevel.CHEF_ONLY
60
+ assert tag_to_level(None) == AudienceLevel.CHEF_ONLY # type: ignore
61
+
62
+
63
+ # ── AudienceResolver ──────────────────────────────────────────────────────────
64
+
65
+ SAMPLE_CONFIG = {
66
+ "channels": {
67
+ "telegram:1594678363": {
68
+ "name": "Chef DM",
69
+ "context_tag": "@chef-only",
70
+ "members": ["Chef"],
71
+ },
72
+ "-1003785842091": {
73
+ "name": "SKGentis Business",
74
+ "context_tag": "@work:skgentis",
75
+ "members": ["Chef", "JZ", "Luna"],
76
+ },
77
+ "-1003899092893": {
78
+ "name": "Operationors",
79
+ "context_tag": "@work:sovereign",
80
+ "members": ["Chef", "Casey"],
81
+ },
82
+ },
83
+ "people": {
84
+ "Chef": {
85
+ "trust_level": 4,
86
+ "trust_tags": ["@chef-only"],
87
+ "never_share": [],
88
+ },
89
+ "DavidRich": {
90
+ "trust_level": 2,
91
+ "trust_tags": ["@work:chiro", "@work:swapseat"],
92
+ "never_share": ["romantic", "intimate", "worship"],
93
+ },
94
+ "Casey": {
95
+ "trust_level": 2,
96
+ "trust_tags": ["@work:sovereign"],
97
+ "never_share": ["romantic", "intimate", "revenue"],
98
+ },
99
+ "JZ": {
100
+ "trust_level": 2,
101
+ "trust_tags": ["@work:gentis"],
102
+ "never_share": ["romantic", "intimate"],
103
+ },
104
+ "Luna": {
105
+ "trust_level": 2,
106
+ "trust_tags": ["@work:gentis"],
107
+ "never_share": ["romantic", "intimate"],
108
+ },
109
+ },
110
+ }
111
+
112
+
113
+ @pytest.fixture
114
+ def config_path(tmp_path: Path) -> Path:
115
+ p = tmp_path / "audience_config.json"
116
+ p.write_text(json.dumps(SAMPLE_CONFIG))
117
+ return p
118
+
119
+
120
+ @pytest.fixture
121
+ def resolver(config_path: Path) -> AudienceResolver:
122
+ return AudienceResolver(config_path=config_path)
123
+
124
+
125
+ class TestAudienceResolver:
126
+ def test_resolve_chef_dm(self, resolver: AudienceResolver):
127
+ profile = resolver.resolve_audience("telegram:1594678363")
128
+ assert profile.name == "Chef DM"
129
+ assert profile.min_trust == AudienceLevel.CHEF_ONLY
130
+ assert profile.members == ["Chef"]
131
+ assert len(profile.exclusions) == 0
132
+
133
+ def test_resolve_skgentis(self, resolver: AudienceResolver):
134
+ profile = resolver.resolve_audience("-1003785842091")
135
+ assert profile.name == "SKGentis Business"
136
+ # MIN(Chef=4, JZ=2, Luna=2) = 2 (WORK_CIRCLE)
137
+ assert profile.min_trust == AudienceLevel.WORK_CIRCLE
138
+ # Union of JZ.never_share + Luna.never_share + Chef.never_share
139
+ assert "romantic" in profile.exclusions
140
+ assert "intimate" in profile.exclusions
141
+
142
+ def test_resolve_operationors(self, resolver: AudienceResolver):
143
+ profile = resolver.resolve_audience("-1003899092893")
144
+ assert profile.min_trust == AudienceLevel.WORK_CIRCLE
145
+ assert "revenue" in profile.exclusions # Casey's never_share
146
+
147
+ def test_unknown_channel_defaults_chef_only(self, resolver: AudienceResolver):
148
+ profile = resolver.resolve_audience("unknown-channel-123")
149
+ assert profile.min_trust == AudienceLevel.CHEF_ONLY
150
+ assert profile.name == "[unknown]"
151
+
152
+ def test_get_person_trust(self, resolver: AudienceResolver):
153
+ assert resolver.get_person_trust("Chef") == AudienceLevel.CHEF_ONLY
154
+ assert resolver.get_person_trust("DavidRich") == AudienceLevel.WORK_CIRCLE
155
+ assert resolver.get_person_trust("Casey") == AudienceLevel.WORK_CIRCLE
156
+
157
+ def test_unknown_person_defaults_public(self, resolver: AudienceResolver):
158
+ assert resolver.get_person_trust("RandomStranger") == AudienceLevel.PUBLIC
159
+
160
+
161
+ class TestIsMemoryAllowed:
162
+ def test_public_memory_in_work_channel(self, resolver: AudienceResolver):
163
+ audience = resolver.resolve_audience("-1003785842091")
164
+ # @public(0) <= WORK_CIRCLE(2) → allowed
165
+ assert resolver.is_memory_allowed("@public", audience) is True
166
+
167
+ def test_chef_only_memory_in_work_channel(self, resolver: AudienceResolver):
168
+ audience = resolver.resolve_audience("-1003785842091")
169
+ # @chef-only(4) > WORK_CIRCLE(2) → blocked
170
+ assert resolver.is_memory_allowed("@chef-only", audience) is False
171
+
172
+ def test_chef_only_memory_in_chef_dm(self, resolver: AudienceResolver):
173
+ audience = resolver.resolve_audience("telegram:1594678363")
174
+ # @chef-only(4) <= CHEF_ONLY(4) → allowed
175
+ assert resolver.is_memory_allowed("@chef-only", audience) is True
176
+
177
+ def test_work_circle_memory_in_work_channel(self, resolver: AudienceResolver):
178
+ audience = resolver.resolve_audience("-1003785842091")
179
+ # @work-circle(2) <= WORK_CIRCLE(2) → allowed
180
+ assert resolver.is_memory_allowed("@work-circle", audience) is True
181
+
182
+ def test_inner_circle_blocked_in_work_channel(self, resolver: AudienceResolver):
183
+ audience = resolver.resolve_audience("-1003785842091")
184
+ # @inner-circle(3) > WORK_CIRCLE(2) → blocked
185
+ assert resolver.is_memory_allowed("@inner-circle", audience) is False
186
+
187
+ def test_exclusion_blocks_memory(self, resolver: AudienceResolver):
188
+ audience = resolver.resolve_audience("-1003785842091")
189
+ # Even at @work-circle level, "romantic" tag triggers exclusion
190
+ assert resolver.is_memory_allowed(
191
+ "@work-circle", audience, memory_tags=["romantic"]
192
+ ) is False
193
+
194
+ def test_no_exclusion_allows_memory(self, resolver: AudienceResolver):
195
+ audience = resolver.resolve_audience("-1003785842091")
196
+ assert resolver.is_memory_allowed(
197
+ "@work-circle", audience, memory_tags=["project", "technical"]
198
+ ) is True
199
+
200
+ def test_empty_tag_defaults_chef_only(self, resolver: AudienceResolver):
201
+ audience = resolver.resolve_audience("-1003785842091")
202
+ # Empty context_tag → @chef-only → blocked in work channel
203
+ assert resolver.is_memory_allowed("", audience) is False
204
+
205
+ def test_bash_wedding_vows_blocked_in_business(self, resolver: AudienceResolver):
206
+ """The incident that started it all — Bash Wedding Vows must NOT
207
+ leak into DavidRich's chiro channel or any business channel."""
208
+ audience = resolver.resolve_audience("-1003785842091")
209
+ # Bash Wedding Vows are @chef-only + tagged "intimate"
210
+ assert resolver.is_memory_allowed(
211
+ "@chef-only", audience, memory_tags=["intimate", "love", "bash-vows"]
212
+ ) is False
213
+
214
+ def test_bash_wedding_vows_allowed_in_chef_dm(self, resolver: AudienceResolver):
215
+ audience = resolver.resolve_audience("telegram:1594678363")
216
+ assert resolver.is_memory_allowed(
217
+ "@chef-only", audience, memory_tags=["intimate", "love", "bash-vows"]
218
+ ) is True
219
+
220
+
221
+ class TestMissingConfig:
222
+ def test_missing_config_file(self, tmp_path: Path):
223
+ resolver = AudienceResolver(config_path=tmp_path / "nonexistent.json")
224
+ # Should not crash, just return conservative defaults
225
+ profile = resolver.resolve_audience("anything")
226
+ assert profile.min_trust == AudienceLevel.CHEF_ONLY
227
+
228
+ def test_empty_config(self, tmp_path: Path):
229
+ p = tmp_path / "empty.json"
230
+ p.write_text("{}")
231
+ resolver = AudienceResolver(config_path=p)
232
+ profile = resolver.resolve_audience("anything")
233
+ assert profile.min_trust == AudienceLevel.CHEF_ONLY
@@ -8,10 +8,9 @@ from click.testing import CliRunner
8
8
 
9
9
  from skmemory.backends.sqlite_backend import SQLiteBackend
10
10
  from skmemory.cli import cli
11
- from skmemory.models import EmotionalSnapshot, MemoryLayer
11
+ from skmemory.models import EmotionalSnapshot
12
12
  from skmemory.store import MemoryStore
13
13
 
14
-
15
14
  # ---------------------------------------------------------------------------
16
15
  # Fixtures
17
16
  # ---------------------------------------------------------------------------
@@ -69,17 +68,13 @@ class TestListBackups:
69
68
 
70
69
  def test_lists_all_backup_files(self, backend, tmp_path):
71
70
  backup_dir = tmp_path / "backups"
72
- _make_backup_files(
73
- backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"]
74
- )
71
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"])
75
72
  results = backend.list_backups(str(backup_dir))
76
73
  assert len(results) == 3
77
74
 
78
75
  def test_sorted_newest_first(self, backend, tmp_path):
79
76
  backup_dir = tmp_path / "backups"
80
- _make_backup_files(
81
- backup_dir, ["2026-01-01", "2026-01-03", "2026-01-02"]
82
- )
77
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-03", "2026-01-02"])
83
78
  results = backend.list_backups(str(backup_dir))
84
79
  dates = [r["date"] for r in results]
85
80
  assert dates == ["2026-01-03", "2026-01-02", "2026-01-01"]
@@ -156,18 +151,14 @@ class TestPruneBackups:
156
151
 
157
152
  def test_deleted_files_are_gone(self, backend, tmp_path):
158
153
  backup_dir = tmp_path / "backups"
159
- _make_backup_files(
160
- backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"]
161
- )
154
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"])
162
155
  deleted = backend.prune_backups(keep=1, backup_dir=str(backup_dir))
163
156
  for path in deleted:
164
157
  assert not Path(path).exists()
165
158
 
166
159
  def test_store_delegates_to_backend(self, store, tmp_path):
167
160
  backup_dir = store.primary.base_path.parent / "backups"
168
- _make_backup_files(
169
- backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"]
170
- )
161
+ _make_backup_files(backup_dir, ["2026-01-01", "2026-01-02", "2026-01-03"])
171
162
  deleted = store.prune_backups(keep=1)
172
163
  assert len(deleted) == 2
173
164