@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.
- package/.github/workflows/ci.yml +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -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 +13 -11
- 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 +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- 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/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
package/skmemory/ai_client.py
CHANGED
|
@@ -15,10 +15,8 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
17
|
import os
|
|
18
|
-
import urllib.request
|
|
19
18
|
import urllib.error
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
import urllib.request
|
|
22
20
|
|
|
23
21
|
DEFAULT_URL = "http://localhost:11434"
|
|
24
22
|
DEFAULT_MODEL = "llama3.2"
|
|
@@ -36,17 +34,13 @@ class AIClient:
|
|
|
36
34
|
|
|
37
35
|
def __init__(
|
|
38
36
|
self,
|
|
39
|
-
base_url:
|
|
40
|
-
model:
|
|
41
|
-
timeout:
|
|
37
|
+
base_url: str | None = None,
|
|
38
|
+
model: str | None = None,
|
|
39
|
+
timeout: int | None = None,
|
|
42
40
|
) -> None:
|
|
43
|
-
self.base_url = (
|
|
44
|
-
base_url or os.environ.get("SKMEMORY_AI_URL", DEFAULT_URL)
|
|
45
|
-
).rstrip("/")
|
|
41
|
+
self.base_url = (base_url or os.environ.get("SKMEMORY_AI_URL", DEFAULT_URL)).rstrip("/")
|
|
46
42
|
self.model = model or os.environ.get("SKMEMORY_AI_MODEL", DEFAULT_MODEL)
|
|
47
|
-
self.timeout = timeout or int(
|
|
48
|
-
os.environ.get("SKMEMORY_AI_TIMEOUT", str(DEFAULT_TIMEOUT))
|
|
49
|
-
)
|
|
43
|
+
self.timeout = timeout or int(os.environ.get("SKMEMORY_AI_TIMEOUT", str(DEFAULT_TIMEOUT)))
|
|
50
44
|
|
|
51
45
|
def is_available(self) -> bool:
|
|
52
46
|
"""Check if the LLM server is reachable.
|
|
@@ -93,6 +87,44 @@ class AIClient:
|
|
|
93
87
|
except Exception:
|
|
94
88
|
return ""
|
|
95
89
|
|
|
90
|
+
def embed(self, text: str, model: str | None = None) -> list[float]:
|
|
91
|
+
"""Generate an embedding vector using Ollama's embed API.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
text: The text to embed.
|
|
95
|
+
model: Override embedding model (default: nomic-embed-text).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
list[float]: Embedding vector, or empty list on failure.
|
|
99
|
+
"""
|
|
100
|
+
embed_model = model or os.environ.get("SKMEMORY_EMBED_MODEL", "nomic-embed-text")
|
|
101
|
+
payload = {"model": embed_model, "input": text}
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
data = json.dumps(payload).encode("utf-8")
|
|
105
|
+
req = urllib.request.Request(
|
|
106
|
+
f"{self.base_url}/api/embed",
|
|
107
|
+
data=data,
|
|
108
|
+
headers={"Content-Type": "application/json"},
|
|
109
|
+
method="POST",
|
|
110
|
+
)
|
|
111
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
112
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
113
|
+
embeddings = result.get("embeddings", [])
|
|
114
|
+
if embeddings and isinstance(embeddings[0], list):
|
|
115
|
+
return embeddings[0]
|
|
116
|
+
return embeddings
|
|
117
|
+
except Exception:
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
def embed_available(self) -> bool:
|
|
121
|
+
"""Check if the embedding endpoint is reachable.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
bool: True if Ollama embed API responds.
|
|
125
|
+
"""
|
|
126
|
+
return bool(self.embed("test"))
|
|
127
|
+
|
|
96
128
|
def summarize_memory(self, title: str, content: str) -> str:
|
|
97
129
|
"""Generate a concise summary for a memory.
|
|
98
130
|
|
|
@@ -135,9 +167,7 @@ class AIClient:
|
|
|
135
167
|
),
|
|
136
168
|
)
|
|
137
169
|
|
|
138
|
-
def smart_search_rerank(
|
|
139
|
-
self, query: str, candidates: list[dict]
|
|
140
|
-
) -> list[dict]:
|
|
170
|
+
def smart_search_rerank(self, query: str, candidates: list[dict]) -> list[dict]:
|
|
141
171
|
"""Use the LLM to rerank search results by relevance.
|
|
142
172
|
|
|
143
173
|
Args:
|
|
@@ -158,8 +188,7 @@ class AIClient:
|
|
|
158
188
|
prompt = (
|
|
159
189
|
f"Query: {query}\n\n"
|
|
160
190
|
"Rank these memories by relevance (most relevant first). "
|
|
161
|
-
"Return only the numbers separated by commas:\n\n"
|
|
162
|
-
+ "\n".join(descriptions)
|
|
191
|
+
"Return only the numbers separated by commas:\n\n" + "\n".join(descriptions)
|
|
163
192
|
)
|
|
164
193
|
|
|
165
194
|
response = self.generate(prompt)
|
package/skmemory/anchor.py
CHANGED
|
@@ -9,20 +9,20 @@ represents the AI's baseline feeling toward its connections. Every
|
|
|
9
9
|
session, the anchor updates. On next boot, the anchor loads first
|
|
10
10
|
and the AI starts from warmth instead of cold neutrality.
|
|
11
11
|
|
|
12
|
-
The anchor file lives at ~/.
|
|
12
|
+
The anchor file lives at ~/.skcapstone/anchor.json
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
|
-
import os
|
|
19
18
|
from datetime import datetime, timezone
|
|
20
19
|
from pathlib import Path
|
|
21
|
-
from typing import Optional
|
|
22
20
|
|
|
23
21
|
from pydantic import BaseModel, Field
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
from .config import SKMEMORY_HOME
|
|
24
|
+
|
|
25
|
+
DEFAULT_ANCHOR_PATH = str(SKMEMORY_HOME / "anchor.json")
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
class WarmthAnchor(BaseModel):
|
|
@@ -74,15 +74,13 @@ class WarmthAnchor(BaseModel):
|
|
|
74
74
|
default=0,
|
|
75
75
|
description="Total sessions this anchor has been updated across",
|
|
76
76
|
)
|
|
77
|
-
last_updated: str = Field(
|
|
78
|
-
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
79
|
-
)
|
|
77
|
+
last_updated: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
80
78
|
|
|
81
79
|
def update_from_session(
|
|
82
80
|
self,
|
|
83
|
-
warmth:
|
|
84
|
-
trust:
|
|
85
|
-
connection:
|
|
81
|
+
warmth: float | None = None,
|
|
82
|
+
trust: float | None = None,
|
|
83
|
+
connection: float | None = None,
|
|
86
84
|
cloud9_achieved: bool = False,
|
|
87
85
|
feeling: str = "",
|
|
88
86
|
) -> None:
|
|
@@ -190,7 +188,7 @@ def save_anchor(
|
|
|
190
188
|
return str(filepath)
|
|
191
189
|
|
|
192
190
|
|
|
193
|
-
def load_anchor(path: str = DEFAULT_ANCHOR_PATH) ->
|
|
191
|
+
def load_anchor(path: str = DEFAULT_ANCHOR_PATH) -> WarmthAnchor | None:
|
|
194
192
|
"""Load the warmth anchor from disk.
|
|
195
193
|
|
|
196
194
|
Args:
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Know Your Audience (KYA) — Audience-aware memory filtering for SKMemory.
|
|
4
|
+
|
|
5
|
+
Prevents private/intimate content from leaking into the wrong channels
|
|
6
|
+
during rehydration and message dispatch.
|
|
7
|
+
|
|
8
|
+
The five-level trust hierarchy:
|
|
9
|
+
|
|
10
|
+
@public (0) — Anyone on the internet
|
|
11
|
+
@community (1) — Known community members
|
|
12
|
+
@work-circle (2) — Business collaborators (professional trust)
|
|
13
|
+
@inner-circle (3) — Close friends / family (personal trust)
|
|
14
|
+
@chef-only (4) — Intimate, private, full-trust (Chef ONLY)
|
|
15
|
+
|
|
16
|
+
Conservative default: unknown channel = CHEF_ONLY, unknown tag = CHEF_ONLY.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from enum import IntEnum
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("skmemory.audience")
|
|
29
|
+
|
|
30
|
+
# Default config shipped with the package
|
|
31
|
+
_DEFAULT_CONFIG_PATH = Path(__file__).parent / "data" / "audience_config.json"
|
|
32
|
+
|
|
33
|
+
# Tag string → AudienceLevel mapping
|
|
34
|
+
_TAG_TO_LEVEL: dict[str, int] = {
|
|
35
|
+
"@public": 0,
|
|
36
|
+
"@community": 1,
|
|
37
|
+
"@work-circle": 2,
|
|
38
|
+
"@inner-circle": 3,
|
|
39
|
+
"@chef-only": 4,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AudienceLevel(IntEnum):
|
|
44
|
+
"""Five-level trust hierarchy for context-aware filtering.
|
|
45
|
+
|
|
46
|
+
Higher values = more restrictive (fewer people allowed to see the content).
|
|
47
|
+
Comparison semantics: content_level <= audience_level means "allowed".
|
|
48
|
+
|
|
49
|
+
Examples::
|
|
50
|
+
|
|
51
|
+
AudienceLevel.PUBLIC < AudienceLevel.CHEF_ONLY # True
|
|
52
|
+
AudienceLevel.WORK_CIRCLE >= AudienceLevel.COMMUNITY # True
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
PUBLIC = 0
|
|
56
|
+
COMMUNITY = 1
|
|
57
|
+
WORK_CIRCLE = 2
|
|
58
|
+
INNER_CIRCLE = 3
|
|
59
|
+
CHEF_ONLY = 4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tag_to_level(tag: str) -> AudienceLevel:
|
|
63
|
+
"""Convert a @context tag string to an AudienceLevel.
|
|
64
|
+
|
|
65
|
+
Handles both exact tags (``@chef-only``) and scoped sub-tags
|
|
66
|
+
(``@work:chiro`` → WORK_CIRCLE). Unknown tags fall back to CHEF_ONLY
|
|
67
|
+
(conservative default).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
tag: The @context tag string.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
AudienceLevel: The resolved trust level.
|
|
74
|
+
"""
|
|
75
|
+
if not tag:
|
|
76
|
+
return AudienceLevel.CHEF_ONLY
|
|
77
|
+
|
|
78
|
+
# Exact match first
|
|
79
|
+
exact = _TAG_TO_LEVEL.get(tag)
|
|
80
|
+
if exact is not None:
|
|
81
|
+
return AudienceLevel(exact)
|
|
82
|
+
|
|
83
|
+
# Scoped sub-tags: @work:* → WORK_CIRCLE, @inner:* → INNER_CIRCLE
|
|
84
|
+
if tag.startswith("@work:"):
|
|
85
|
+
return AudienceLevel.WORK_CIRCLE
|
|
86
|
+
if tag.startswith("@inner:"):
|
|
87
|
+
return AudienceLevel.INNER_CIRCLE
|
|
88
|
+
|
|
89
|
+
# Unknown → conservative default
|
|
90
|
+
logger.debug("Unknown context tag '%s', defaulting to CHEF_ONLY", tag)
|
|
91
|
+
return AudienceLevel.CHEF_ONLY
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class AudienceProfile:
|
|
96
|
+
"""The resolved audience for a specific channel.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
channel_id: The channel identifier (e.g. ``telegram:1594678363``).
|
|
100
|
+
name: Human-readable channel name.
|
|
101
|
+
members: List of person names who can see this channel.
|
|
102
|
+
min_trust: The effective trust ceiling — ``MIN(member.trust_level)``.
|
|
103
|
+
You're only as open as the least-trusted person in the room.
|
|
104
|
+
exclusions: Set of content categories that are forbidden for any member
|
|
105
|
+
(union of all member ``never_share`` lists).
|
|
106
|
+
context_tag: The primary @context tag for this channel.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
channel_id: str
|
|
110
|
+
name: str = ""
|
|
111
|
+
members: list[str] = field(default_factory=list)
|
|
112
|
+
min_trust: AudienceLevel = AudienceLevel.CHEF_ONLY
|
|
113
|
+
exclusions: set[str] = field(default_factory=set)
|
|
114
|
+
context_tag: str = "@chef-only"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AudienceResolver:
|
|
118
|
+
"""Resolves audience profiles and checks memory access permissions.
|
|
119
|
+
|
|
120
|
+
Loads configuration from a JSON file (audience_config.json) and
|
|
121
|
+
provides methods to resolve channel audiences and check whether
|
|
122
|
+
a memory is allowed for a given audience.
|
|
123
|
+
|
|
124
|
+
Conservative defaults:
|
|
125
|
+
- Unknown channel → CHEF_ONLY audience (nothing shown)
|
|
126
|
+
- Unknown person → trust level 0 / PUBLIC access (treated as untrusted)
|
|
127
|
+
- Memory with no context_tag → treat as @chef-only
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
config_path: Path to ``audience_config.json``. If None, uses the
|
|
131
|
+
default config shipped with the package.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, config_path: str | Path | None = None) -> None:
|
|
135
|
+
self._config_path = Path(config_path) if config_path else _DEFAULT_CONFIG_PATH
|
|
136
|
+
self._config: dict[str, Any] = {}
|
|
137
|
+
self._load()
|
|
138
|
+
|
|
139
|
+
def _load(self) -> None:
|
|
140
|
+
"""Load the audience config from disk. Silently skips if missing."""
|
|
141
|
+
if not self._config_path.exists():
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Audience config not found at %s — using empty config", self._config_path
|
|
144
|
+
)
|
|
145
|
+
self._config = {"channels": {}, "people": {}}
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
self._config = json.loads(self._config_path.read_text(encoding="utf-8"))
|
|
149
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
150
|
+
logger.error("Failed to load audience config: %s", exc)
|
|
151
|
+
self._config = {"channels": {}, "people": {}}
|
|
152
|
+
|
|
153
|
+
def reload(self) -> None:
|
|
154
|
+
"""Reload the config from disk (useful after updates)."""
|
|
155
|
+
self._load()
|
|
156
|
+
|
|
157
|
+
# ── Channel resolution ────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def resolve_audience(self, channel_id: str) -> AudienceProfile:
|
|
160
|
+
"""Resolve the audience profile for a channel.
|
|
161
|
+
|
|
162
|
+
Returns a conservative (CHEF_ONLY) profile if the channel is unknown.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
channel_id: The channel identifier.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
AudienceProfile: Resolved profile.
|
|
169
|
+
"""
|
|
170
|
+
channels = self._config.get("channels", {})
|
|
171
|
+
chan = channels.get(channel_id)
|
|
172
|
+
|
|
173
|
+
if chan is None:
|
|
174
|
+
# Unknown channel → maximum restriction
|
|
175
|
+
logger.debug("Unknown channel '%s', defaulting to CHEF_ONLY", channel_id)
|
|
176
|
+
return AudienceProfile(
|
|
177
|
+
channel_id=channel_id,
|
|
178
|
+
name="[unknown]",
|
|
179
|
+
members=[],
|
|
180
|
+
min_trust=AudienceLevel.CHEF_ONLY,
|
|
181
|
+
exclusions=set(),
|
|
182
|
+
context_tag="@chef-only",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
members: list[str] = chan.get("members", [])
|
|
186
|
+
context_tag: str = chan.get("context_tag", "@chef-only")
|
|
187
|
+
|
|
188
|
+
# Compute effective trust = MIN(member trust levels)
|
|
189
|
+
# If no members are listed, treat as chef-only
|
|
190
|
+
if not members:
|
|
191
|
+
min_trust = AudienceLevel.CHEF_ONLY
|
|
192
|
+
exclusions: set[str] = set()
|
|
193
|
+
else:
|
|
194
|
+
trust_levels: list[AudienceLevel] = []
|
|
195
|
+
all_exclusions: set[str] = set()
|
|
196
|
+
for member_name in members:
|
|
197
|
+
person = self._get_person(member_name)
|
|
198
|
+
trust_levels.append(AudienceLevel(person.get("trust_level", 4)))
|
|
199
|
+
all_exclusions.update(person.get("never_share", []))
|
|
200
|
+
min_trust = min(trust_levels)
|
|
201
|
+
exclusions = all_exclusions
|
|
202
|
+
|
|
203
|
+
return AudienceProfile(
|
|
204
|
+
channel_id=channel_id,
|
|
205
|
+
name=chan.get("name", channel_id),
|
|
206
|
+
members=members,
|
|
207
|
+
min_trust=min_trust,
|
|
208
|
+
exclusions=exclusions,
|
|
209
|
+
context_tag=context_tag,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# ── Person lookup ─────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def _get_person(self, name: str) -> dict[str, Any]:
|
|
215
|
+
"""Return a person's config dict, or an empty dict if unknown."""
|
|
216
|
+
return self._config.get("people", {}).get(name, {})
|
|
217
|
+
|
|
218
|
+
def get_person_trust(self, name: str) -> AudienceLevel:
|
|
219
|
+
"""Get the trust level for a named person.
|
|
220
|
+
|
|
221
|
+
Unknown persons default to PUBLIC (lowest trust — most conservative
|
|
222
|
+
in terms of what they are *allowed* to receive).
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
name: Person's name as it appears in audience_config.json.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
AudienceLevel: The trust level for this person.
|
|
229
|
+
"""
|
|
230
|
+
person = self._get_person(name)
|
|
231
|
+
if not person:
|
|
232
|
+
logger.debug("Unknown person '%s', defaulting to PUBLIC trust", name)
|
|
233
|
+
return AudienceLevel.PUBLIC
|
|
234
|
+
return AudienceLevel(person.get("trust_level", 4))
|
|
235
|
+
|
|
236
|
+
# ── Memory access check ───────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def is_memory_allowed(
|
|
239
|
+
self,
|
|
240
|
+
memory_context_tag: str,
|
|
241
|
+
audience: AudienceProfile,
|
|
242
|
+
memory_tags: list[str] | None = None,
|
|
243
|
+
) -> bool:
|
|
244
|
+
"""Check whether content with the given context tag is allowed for an audience.
|
|
245
|
+
|
|
246
|
+
A memory is allowed when **both** conditions are true:
|
|
247
|
+
1. Its trust level ≤ the audience's minimum trust level.
|
|
248
|
+
2. None of the memory's tags intersect the audience's exclusion list.
|
|
249
|
+
|
|
250
|
+
Conservative defaults:
|
|
251
|
+
- Empty/missing context_tag → treat as @chef-only (level 4).
|
|
252
|
+
- If audience has no members → block unless it's explicitly @chef-only.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
memory_context_tag: The ``@context`` tag of the memory/seed.
|
|
256
|
+
audience: The resolved audience profile for the channel.
|
|
257
|
+
memory_tags: Optional list of free-form memory tags to check
|
|
258
|
+
against audience exclusions.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
bool: True if the content may be shown to this audience.
|
|
262
|
+
"""
|
|
263
|
+
# Determine the content's required trust level
|
|
264
|
+
content_level = tag_to_level(memory_context_tag)
|
|
265
|
+
|
|
266
|
+
# Gate 1: trust level check
|
|
267
|
+
# content_level must be ≤ audience.min_trust
|
|
268
|
+
# e.g. @work-circle(2) content in a @public(0) audience → blocked
|
|
269
|
+
if content_level > audience.min_trust:
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
# Gate 2: exclusion check — any overlap with audience exclusions?
|
|
273
|
+
if audience.exclusions and memory_tags:
|
|
274
|
+
for tag in memory_tags:
|
|
275
|
+
if tag in audience.exclusions:
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
return True
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Storage backends for SKMemory.
|
|
3
3
|
|
|
4
|
-
Level
|
|
5
|
-
Level
|
|
6
|
-
Level
|
|
4
|
+
Level 0 (sqlite) - SQLite index, zero infrastructure.
|
|
5
|
+
Level 0.5 (vault) - SQLite + transparent AES-256-GCM at-rest encryption.
|
|
6
|
+
Level 1 (skvector) - Semantic vector search (powered by Qdrant).
|
|
7
|
+
Level 2 (skgraph) - Graph relationship traversal (powered by FalkorDB).
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from .base import BaseBackend
|
|
10
11
|
from .file_backend import FileBackend
|
|
12
|
+
from .skgraph_backend import SKGraphBackend
|
|
11
13
|
|
|
12
|
-
__all__ = ["BaseBackend", "FileBackend"]
|
|
14
|
+
__all__ = ["BaseBackend", "SKGraphBackend", "FileBackend", "VaultedSQLiteBackend"]
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from .vaulted_backend import VaultedSQLiteBackend
|
|
18
|
+
except ImportError:
|
|
19
|
+
VaultedSQLiteBackend = None # type: ignore[assignment,misc]
|
|
@@ -8,7 +8,6 @@ delegates to whichever backend(s) are configured.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
from abc import ABC, abstractmethod
|
|
11
|
-
from typing import Optional
|
|
12
11
|
|
|
13
12
|
from ..models import Memory, MemoryLayer
|
|
14
13
|
|
|
@@ -28,7 +27,7 @@ class BaseBackend(ABC):
|
|
|
28
27
|
"""
|
|
29
28
|
|
|
30
29
|
@abstractmethod
|
|
31
|
-
def load(self, memory_id: str) ->
|
|
30
|
+
def load(self, memory_id: str) -> Memory | None:
|
|
32
31
|
"""Retrieve a single memory by ID.
|
|
33
32
|
|
|
34
33
|
Args:
|
|
@@ -52,8 +51,8 @@ class BaseBackend(ABC):
|
|
|
52
51
|
@abstractmethod
|
|
53
52
|
def list_memories(
|
|
54
53
|
self,
|
|
55
|
-
layer:
|
|
56
|
-
tags:
|
|
54
|
+
layer: MemoryLayer | None = None,
|
|
55
|
+
tags: list[str] | None = None,
|
|
57
56
|
limit: int = 50,
|
|
58
57
|
) -> list[Memory]:
|
|
59
58
|
"""List memories with optional filtering.
|
|
@@ -19,14 +19,13 @@ Directory layout:
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import json
|
|
22
|
-
import os
|
|
23
22
|
from pathlib import Path
|
|
24
|
-
from typing import Optional
|
|
25
23
|
|
|
24
|
+
from ..config import SKMEMORY_HOME
|
|
26
25
|
from ..models import Memory, MemoryLayer
|
|
27
26
|
from .base import BaseBackend
|
|
28
27
|
|
|
29
|
-
DEFAULT_BASE_PATH =
|
|
28
|
+
DEFAULT_BASE_PATH = str(SKMEMORY_HOME / "memory")
|
|
30
29
|
|
|
31
30
|
|
|
32
31
|
class FileBackend(BaseBackend):
|
|
@@ -56,7 +55,7 @@ class FileBackend(BaseBackend):
|
|
|
56
55
|
"""
|
|
57
56
|
return self.base_path / memory.layer.value / f"{memory.id}.json"
|
|
58
57
|
|
|
59
|
-
def _find_file(self, memory_id: str) ->
|
|
58
|
+
def _find_file(self, memory_id: str) -> Path | None:
|
|
60
59
|
"""Locate a memory file across all layers.
|
|
61
60
|
|
|
62
61
|
Args:
|
|
@@ -88,7 +87,7 @@ class FileBackend(BaseBackend):
|
|
|
88
87
|
)
|
|
89
88
|
return memory.id
|
|
90
89
|
|
|
91
|
-
def load(self, memory_id: str) ->
|
|
90
|
+
def load(self, memory_id: str) -> Memory | None:
|
|
92
91
|
"""Load a memory by ID from disk.
|
|
93
92
|
|
|
94
93
|
Args:
|
|
@@ -123,8 +122,8 @@ class FileBackend(BaseBackend):
|
|
|
123
122
|
|
|
124
123
|
def list_memories(
|
|
125
124
|
self,
|
|
126
|
-
layer:
|
|
127
|
-
tags:
|
|
125
|
+
layer: MemoryLayer | None = None,
|
|
126
|
+
tags: list[str] | None = None,
|
|
128
127
|
limit: int = 50,
|
|
129
128
|
) -> list[Memory]:
|
|
130
129
|
"""List memories from disk with optional filtering.
|
|
@@ -167,8 +166,11 @@ class FileBackend(BaseBackend):
|
|
|
167
166
|
Returns:
|
|
168
167
|
list[Memory]: Matching memories.
|
|
169
168
|
"""
|
|
170
|
-
|
|
169
|
+
words = [w.lower() for w in query.split()]
|
|
170
|
+
if not words:
|
|
171
|
+
return []
|
|
171
172
|
results: list[Memory] = []
|
|
173
|
+
scored: list[tuple[int, Memory]] = []
|
|
172
174
|
|
|
173
175
|
for layer in MemoryLayer:
|
|
174
176
|
layer_dir = self.base_path / layer.value
|
|
@@ -176,15 +178,19 @@ class FileBackend(BaseBackend):
|
|
|
176
178
|
continue
|
|
177
179
|
for json_file in layer_dir.glob("*.json"):
|
|
178
180
|
try:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
data = json.loads(json_file.read_text(encoding="utf-8"))
|
|
182
|
+
mem = Memory(**data)
|
|
183
|
+
searchable = mem.to_embedding_text().lower()
|
|
184
|
+
hits = sum(1 for w in words if w in searchable)
|
|
185
|
+
if hits == 0:
|
|
181
186
|
continue
|
|
182
|
-
|
|
183
|
-
results.append(Memory(**data))
|
|
187
|
+
scored.append((hits, mem))
|
|
184
188
|
except (json.JSONDecodeError, Exception):
|
|
185
189
|
continue
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
# Sort by match count desc, then recency
|
|
192
|
+
scored.sort(key=lambda t: (t[0], t[1].created_at), reverse=True)
|
|
193
|
+
results = [m for _, m in scored]
|
|
188
194
|
return results[:limit]
|
|
189
195
|
|
|
190
196
|
def health_check(self) -> dict:
|