@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
@@ -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
- from typing import Optional
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: Optional[str] = None,
40
- model: Optional[str] = None,
41
- timeout: Optional[int] = None,
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)
@@ -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 ~/.skmemory/anchor.json
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
- DEFAULT_ANCHOR_PATH = os.path.expanduser("~/.skmemory/anchor.json")
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: Optional[float] = None,
84
- trust: Optional[float] = None,
85
- connection: Optional[float] = None,
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) -> Optional[WarmthAnchor]:
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 1 (file) - JSON files on disk, zero infrastructure.
5
- Level 2 (qdrant) - Vector search via Qdrant for semantic recall.
6
- Level 3 (graph) - FalkorDB graph relationships between memories.
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) -> Optional[Memory]:
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: Optional[MemoryLayer] = None,
56
- tags: Optional[list[str]] = None,
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 = os.path.expanduser("~/.skmemory/memories")
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) -> Optional[Path]:
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) -> Optional[Memory]:
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: Optional[MemoryLayer] = None,
127
- tags: Optional[list[str]] = None,
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
- query_lower = query.lower()
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
- raw = json_file.read_text(encoding="utf-8")
180
- if query_lower not in raw.lower():
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
- data = json.loads(raw)
183
- results.append(Memory(**data))
187
+ scored.append((hits, mem))
184
188
  except (json.JSONDecodeError, Exception):
185
189
  continue
186
190
 
187
- results.sort(key=lambda m: m.created_at, reverse=True)
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: