@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.
- package/.github/workflows/ci.yml +4 -4
- package/.github/workflows/publish.yml +4 -5
- package/ARCHITECTURE.md +298 -0
- package/CHANGELOG.md +27 -1
- package/README.md +6 -0
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/package.json +1 -1
- package/pyproject.toml +5 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +6 -4
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +7 -5
- package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
- package/seeds/lumina-kingdom-founding.seed.json +9 -7
- package/seeds/lumina-pma-signed.seed.json +8 -6
- package/seeds/lumina-singular-achievement.seed.json +8 -6
- package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
- package/seeds/plant-lumina-seeds.py +2 -2
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skmemory/__init__.py +16 -13
- package/skmemory/agents.py +10 -10
- package/skmemory/ai_client.py +10 -21
- package/skmemory/anchor.py +5 -9
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +1 -1
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +18 -13
- package/skmemory/backends/skgraph_backend.py +7 -19
- package/skmemory/backends/skvector_backend.py +7 -18
- package/skmemory/backends/sqlite_backend.py +115 -32
- package/skmemory/backends/vaulted_backend.py +7 -9
- package/skmemory/cli.py +146 -78
- package/skmemory/config.py +11 -13
- package/skmemory/context_loader.py +21 -23
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +36 -31
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +30 -40
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/telegram.py +42 -13
- package/skmemory/importers/telegram_api.py +152 -60
- package/skmemory/journal.py +3 -7
- package/skmemory/lovenote.py +4 -11
- package/skmemory/mcp_server.py +182 -29
- package/skmemory/models.py +10 -8
- package/skmemory/openclaw.py +14 -22
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +13 -9
- package/skmemory/promotion.py +48 -24
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +144 -18
- package/skmemory/register_mcp.py +1 -2
- package/skmemory/ritual.py +104 -13
- package/skmemory/seeds.py +21 -26
- package/skmemory/setup_wizard.py +40 -52
- package/skmemory/sharing.py +11 -5
- package/skmemory/soul.py +29 -10
- package/skmemory/steelman.py +43 -17
- package/skmemory/store.py +152 -30
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +2 -5
- package/tests/conftest.py +46 -0
- package/tests/integration/conftest.py +6 -6
- package/tests/integration/test_cross_backend.py +4 -9
- package/tests/integration/test_skgraph_live.py +3 -7
- package/tests/integration/test_skvector_live.py +1 -4
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +5 -14
- package/tests/test_endpoint_selector.py +101 -63
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +6 -5
- package/tests/test_fortress_hardening.py +13 -16
- package/tests/test_openclaw.py +1 -4
- package/tests/test_predictive.py +1 -1
- package/tests/test_promotion.py +10 -3
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +18 -14
- package/tests/test_seeds.py +4 -10
- package/tests/test_setup.py +203 -88
- package/tests/test_sharing.py +15 -8
- package/tests/test_skgraph_backend.py +22 -29
- package/tests/test_skvector_backend.py +2 -2
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +2 -3
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +2 -2
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +4 -3
- 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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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 = {
|
|
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 = [
|
|
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 = [
|
|
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):
|
package/tests/test_ai_client.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|