@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
@@ -19,15 +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
 
26
24
  from ..config import SKMEMORY_HOME
27
25
  from ..models import Memory, MemoryLayer
28
26
  from .base import BaseBackend
29
27
 
30
- DEFAULT_BASE_PATH = str(SKMEMORY_HOME)
28
+ DEFAULT_BASE_PATH = str(SKMEMORY_HOME / "memory")
31
29
 
32
30
 
33
31
  class FileBackend(BaseBackend):
@@ -57,7 +55,7 @@ class FileBackend(BaseBackend):
57
55
  """
58
56
  return self.base_path / memory.layer.value / f"{memory.id}.json"
59
57
 
60
- def _find_file(self, memory_id: str) -> Optional[Path]:
58
+ def _find_file(self, memory_id: str) -> Path | None:
61
59
  """Locate a memory file across all layers.
62
60
 
63
61
  Args:
@@ -89,7 +87,7 @@ class FileBackend(BaseBackend):
89
87
  )
90
88
  return memory.id
91
89
 
92
- def load(self, memory_id: str) -> Optional[Memory]:
90
+ def load(self, memory_id: str) -> Memory | None:
93
91
  """Load a memory by ID from disk.
94
92
 
95
93
  Args:
@@ -124,8 +122,8 @@ class FileBackend(BaseBackend):
124
122
 
125
123
  def list_memories(
126
124
  self,
127
- layer: Optional[MemoryLayer] = None,
128
- tags: Optional[list[str]] = None,
125
+ layer: MemoryLayer | None = None,
126
+ tags: list[str] | None = None,
129
127
  limit: int = 50,
130
128
  ) -> list[Memory]:
131
129
  """List memories from disk with optional filtering.
@@ -168,8 +166,11 @@ class FileBackend(BaseBackend):
168
166
  Returns:
169
167
  list[Memory]: Matching memories.
170
168
  """
171
- query_lower = query.lower()
169
+ words = [w.lower() for w in query.split()]
170
+ if not words:
171
+ return []
172
172
  results: list[Memory] = []
173
+ scored: list[tuple[int, Memory]] = []
173
174
 
174
175
  for layer in MemoryLayer:
175
176
  layer_dir = self.base_path / layer.value
@@ -177,15 +178,19 @@ class FileBackend(BaseBackend):
177
178
  continue
178
179
  for json_file in layer_dir.glob("*.json"):
179
180
  try:
180
- raw = json_file.read_text(encoding="utf-8")
181
- 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:
182
186
  continue
183
- data = json.loads(raw)
184
- results.append(Memory(**data))
187
+ scored.append((hits, mem))
185
188
  except (json.JSONDecodeError, Exception):
186
189
  continue
187
190
 
188
- 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]
189
194
  return results[:limit]
190
195
 
191
196
  def health_check(self) -> dict:
@@ -38,10 +38,9 @@ from __future__ import annotations
38
38
 
39
39
  import logging
40
40
  import os
41
- from typing import Optional
42
41
 
43
- from ..models import Memory, MemoryLayer
44
42
  from .. import graph_queries as Q
43
+ from ..models import Memory
45
44
 
46
45
  logger = logging.getLogger(__name__)
47
46
 
@@ -219,11 +218,7 @@ class SKGraphBackend:
219
218
  # PLANTED edge for AI seed memories
220
219
  if memory.source == "seed":
221
220
  creator = next(
222
- (
223
- t.split(":", 1)[1]
224
- for t in memory.tags
225
- if t.startswith("creator:")
226
- ),
221
+ (t.split(":", 1)[1] for t in memory.tags if t.startswith("creator:")),
227
222
  None,
228
223
  )
229
224
  if creator:
@@ -241,7 +236,7 @@ class SKGraphBackend:
241
236
  # Read operations
242
237
  # ─────────────────────────────────────────────────────────
243
238
 
244
- def get(self, memory_id: str) -> Optional[dict]:
239
+ def get(self, memory_id: str) -> dict | None:
245
240
  """Retrieve the graph node properties for a memory by ID.
246
241
 
247
242
  Returns only the properties stored in the graph (no full content).
@@ -544,24 +539,17 @@ class SKGraphBackend:
544
539
 
545
540
  try:
546
541
  node_result = self._graph.query(Q.COUNT_NODES)
547
- node_count = (
548
- node_result.result_set[0][0] if node_result.result_set else 0
549
- )
542
+ node_count = node_result.result_set[0][0] if node_result.result_set else 0
550
543
 
551
544
  edge_result = self._graph.query(Q.COUNT_EDGES)
552
- edge_count = (
553
- edge_result.result_set[0][0] if edge_result.result_set else 0
554
- )
545
+ edge_count = edge_result.result_set[0][0] if edge_result.result_set else 0
555
546
 
556
547
  mem_result = self._graph.query(Q.COUNT_MEMORIES)
557
- memory_count = (
558
- mem_result.result_set[0][0] if mem_result.result_set else 0
559
- )
548
+ memory_count = mem_result.result_set[0][0] if mem_result.result_set else 0
560
549
 
561
550
  tag_result = self._graph.query(Q.TAG_DISTRIBUTION)
562
551
  tag_distribution = [
563
- {"tag": row[0], "memory_count": row[1]}
564
- for row in tag_result.result_set
552
+ {"tag": row[0], "memory_count": row[1]} for row in tag_result.result_set
565
553
  ]
566
554
 
567
555
  return {
@@ -15,9 +15,7 @@ SaaS endpoint: https://cloud.qdrant.io (free cluster available).
15
15
  from __future__ import annotations
16
16
 
17
17
  import hashlib
18
- import json
19
18
  import logging
20
- from typing import Optional
21
19
 
22
20
  from ..models import Memory, MemoryLayer
23
21
  from .base import BaseBackend
@@ -66,7 +64,7 @@ class SKVectorBackend(BaseBackend):
66
64
  def __init__(
67
65
  self,
68
66
  url: str = "http://localhost:6333",
69
- api_key: Optional[str] = None,
67
+ api_key: str | None = None,
70
68
  collection: str = COLLECTION_NAME,
71
69
  embedding_model: str = EMBEDDING_MODEL,
72
70
  ) -> None:
@@ -98,20 +96,15 @@ class SKVectorBackend(BaseBackend):
98
96
  try:
99
97
  from sentence_transformers import SentenceTransformer
100
98
  except ImportError:
101
- logger.warning(
102
- "sentence-transformers not installed: "
103
- "pip install skmemory[skvector]"
104
- )
99
+ logger.warning("sentence-transformers not installed: pip install skmemory[skvector]")
105
100
  return False
106
101
 
107
102
  try:
108
103
  from qdrant_client.http.exceptions import (
109
- ResponseHandlingException,
110
104
  UnexpectedResponse,
111
105
  )
112
106
  except ImportError:
113
107
  UnexpectedResponse = None
114
- ResponseHandlingException = None
115
108
 
116
109
  try:
117
110
  self._client = QdrantClient(url=self.url, api_key=self.api_key)
@@ -219,7 +212,7 @@ class SKVectorBackend(BaseBackend):
219
212
  """Convert a memory ID string to a deterministic Qdrant integer point ID."""
220
213
  return int(hashlib.sha256(memory_id.encode()).hexdigest()[:15], 16)
221
214
 
222
- def load(self, memory_id: str) -> Optional[Memory]:
215
+ def load(self, memory_id: str) -> Memory | None:
223
216
  """Retrieve a memory by ID from Qdrant.
224
217
 
225
218
  Args:
@@ -275,8 +268,8 @@ class SKVectorBackend(BaseBackend):
275
268
 
276
269
  def list_memories(
277
270
  self,
278
- layer: Optional[MemoryLayer] = None,
279
- tags: Optional[list[str]] = None,
271
+ layer: MemoryLayer | None = None,
272
+ tags: list[str] | None = None,
280
273
  limit: int = 50,
281
274
  ) -> list[Memory]:
282
275
  """List memories from Qdrant with filtering.
@@ -301,9 +294,7 @@ class SKVectorBackend(BaseBackend):
301
294
  )
302
295
  if tags:
303
296
  for tag in tags:
304
- must_conditions.append(
305
- FieldCondition(key="tags", match=MatchValue(value=tag))
306
- )
297
+ must_conditions.append(FieldCondition(key="tags", match=MatchValue(value=tag)))
307
298
 
308
299
  scroll_filter = Filter(must=must_conditions) if must_conditions else None
309
300
 
@@ -351,9 +342,7 @@ class SKVectorBackend(BaseBackend):
351
342
  memories = []
352
343
  for scored_point in results:
353
344
  try:
354
- mem = Memory.model_validate_json(
355
- scored_point.payload["memory_json"]
356
- )
345
+ mem = Memory.model_validate_json(scored_point.payload["memory_json"])
357
346
  memories.append(mem)
358
347
  except Exception:
359
348
  continue
@@ -25,16 +25,14 @@ Directory layout (same as FileBackend):
25
25
  from __future__ import annotations
26
26
 
27
27
  import json
28
- import os
29
28
  import sqlite3
30
29
  from pathlib import Path
31
- from typing import Optional
32
30
 
33
31
  from ..config import SKMEMORY_HOME
34
- from ..models import EmotionalSnapshot, Memory, MemoryLayer
32
+ from ..models import Memory, MemoryLayer
35
33
  from .base import BaseBackend
36
34
 
37
- DEFAULT_BASE_PATH = str(SKMEMORY_HOME)
35
+ DEFAULT_BASE_PATH = str(SKMEMORY_HOME / "memory")
38
36
 
39
37
  _SCHEMA = """
40
38
  CREATE TABLE IF NOT EXISTS memories (
@@ -77,10 +75,10 @@ CREATE INDEX IF NOT EXISTS idx_access_count ON memories(access_count DESC);
77
75
 
78
76
  -- NEW: View for active context (today + recent summaries)
79
77
  CREATE VIEW IF NOT EXISTS active_memories AS
80
- SELECT
78
+ SELECT
81
79
  id, title, summary, content_preview, tags, layer, created_at,
82
80
  importance, access_count,
83
- CASE
81
+ CASE
84
82
  WHEN DATE(created_at) = CURRENT_DATE THEN 'today'
85
83
  WHEN DATE(created_at) = DATE('now', '-1 day') THEN 'yesterday'
86
84
  WHEN DATE(created_at) >= DATE('now', '-7 days') THEN 'week'
@@ -88,7 +86,7 @@ SELECT
88
86
  END as context_tier
89
87
  FROM memories
90
88
  WHERE created_at >= DATE('now', '-30 days')
91
- ORDER BY
89
+ ORDER BY
92
90
  context_tier,
93
91
  importance DESC,
94
92
  access_count DESC;
@@ -109,7 +107,7 @@ class SQLiteBackend(BaseBackend):
109
107
  self.base_path = Path(base_path)
110
108
  self._ensure_dirs()
111
109
  self._db_path = self.base_path / "index.db"
112
- self._conn: Optional[sqlite3.Connection] = None
110
+ self._conn: sqlite3.Connection | None = None
113
111
  self._ensure_db()
114
112
 
115
113
  def _ensure_dirs(self) -> None:
@@ -134,8 +132,36 @@ class SQLiteBackend(BaseBackend):
134
132
  return self._conn
135
133
 
136
134
  def _ensure_db(self) -> None:
137
- """Initialize the database schema."""
135
+ """Initialize the database schema, migrating old tables if needed."""
138
136
  conn = self._get_conn()
137
+
138
+ # Migrate: add columns that may be missing from older schemas.
139
+ # We check pragma_table_info first so this is safe on fresh DBs too.
140
+ row = conn.execute(
141
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='memories'"
142
+ ).fetchone()
143
+ if row is not None:
144
+ existing = {r[1] for r in conn.execute("PRAGMA table_info(memories)").fetchall()}
145
+ migrations = [
146
+ (
147
+ "importance",
148
+ "ALTER TABLE memories ADD COLUMN importance REAL NOT NULL DEFAULT 0.5",
149
+ ),
150
+ (
151
+ "access_count",
152
+ "ALTER TABLE memories ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0",
153
+ ),
154
+ ("last_accessed", "ALTER TABLE memories ADD COLUMN last_accessed TEXT"),
155
+ ]
156
+ for col, ddl in migrations:
157
+ if col not in existing:
158
+ conn.execute(ddl)
159
+ conn.commit()
160
+
161
+ # Drop stale view so CREATE VIEW IF NOT EXISTS picks up new columns.
162
+ conn.execute("DROP VIEW IF EXISTS active_memories")
163
+ conn.commit()
164
+
139
165
  conn.executescript(_SCHEMA)
140
166
  conn.commit()
141
167
 
@@ -150,7 +176,7 @@ class SQLiteBackend(BaseBackend):
150
176
  """
151
177
  return self.base_path / memory.layer.value / f"{memory.id}.json"
152
178
 
153
- def _find_file(self, memory_id: str) -> Optional[Path]:
179
+ def _find_file(self, memory_id: str) -> Path | None:
154
180
  """Locate a memory file using the index first, then fallback.
155
181
 
156
182
  Args:
@@ -238,14 +264,14 @@ class SQLiteBackend(BaseBackend):
238
264
  "content_preview": row["content_preview"],
239
265
  "emotional_intensity": row["emotional_intensity"],
240
266
  "emotional_valence": row["emotional_valence"],
241
- "emotional_labels": [l for l in row["emotional_labels"].split(",") if l],
267
+ "emotional_labels": [lbl for lbl in row["emotional_labels"].split(",") if lbl],
242
268
  "cloud9_achieved": bool(row["cloud9_achieved"]),
243
269
  "created_at": row["created_at"],
244
270
  "parent_id": row["parent_id"],
245
271
  "related_ids": [r for r in row["related_ids"].split(",") if r],
246
272
  }
247
273
 
248
- def _row_to_memory(self, row: sqlite3.Row) -> Optional[Memory]:
274
+ def _row_to_memory(self, row: sqlite3.Row) -> Memory | None:
249
275
  """Load the full Memory object from disk using the index path.
250
276
 
251
277
  Args:
@@ -281,7 +307,7 @@ class SQLiteBackend(BaseBackend):
281
307
  self._index_memory(memory, path)
282
308
  return memory.id
283
309
 
284
- def load(self, memory_id: str) -> Optional[Memory]:
310
+ def load(self, memory_id: str) -> Memory | None:
285
311
  """Load a memory by ID, using the index for fast lookup.
286
312
 
287
313
  Args:
@@ -322,8 +348,8 @@ class SQLiteBackend(BaseBackend):
322
348
 
323
349
  def list_memories(
324
350
  self,
325
- layer: Optional[MemoryLayer] = None,
326
- tags: Optional[list[str]] = None,
351
+ layer: MemoryLayer | None = None,
352
+ tags: list[str] | None = None,
327
353
  limit: int = 50,
328
354
  ) -> list[Memory]:
329
355
  """List memories using the index for filtering, loading full objects.
@@ -366,8 +392,8 @@ class SQLiteBackend(BaseBackend):
366
392
 
367
393
  def list_summaries(
368
394
  self,
369
- layer: Optional[MemoryLayer] = None,
370
- tags: Optional[list[str]] = None,
395
+ layer: MemoryLayer | None = None,
396
+ tags: list[str] | None = None,
371
397
  limit: int = 50,
372
398
  min_intensity: float = 0.0,
373
399
  order_by: str = "created_at",
@@ -407,7 +433,19 @@ class SQLiteBackend(BaseBackend):
407
433
 
408
434
  where = " AND ".join(conditions) if conditions else "1=1"
409
435
 
410
- if order_by == "emotional_intensity":
436
+ if order_by == "recency_weighted_intensity":
437
+ # Combine intensity with recency: recent high-intensity memories
438
+ # score higher than old high-intensity ones.
439
+ # julianday('now') - julianday(created_at) gives days ago.
440
+ # Decay: halve the recency bonus every 7 days.
441
+ order = (
442
+ "(emotional_intensity + "
443
+ "CASE WHEN julianday('now') - julianday(created_at) < 1 THEN 5.0 "
444
+ "WHEN julianday('now') - julianday(created_at) < 3 THEN 3.0 "
445
+ "WHEN julianday('now') - julianday(created_at) < 7 THEN 1.5 "
446
+ "ELSE 0.0 END) DESC"
447
+ )
448
+ elif order_by == "emotional_intensity":
411
449
  order = "emotional_intensity DESC"
412
450
  else:
413
451
  order = "created_at DESC"
@@ -424,7 +462,9 @@ class SQLiteBackend(BaseBackend):
424
462
  def search_text(self, query: str, limit: int = 10) -> list[Memory]:
425
463
  """Search memories using the SQLite index (title, summary, preview).
426
464
 
427
- Falls back to full file scan only if the index doesn't find matches.
465
+ For multi-word queries, tries AND first (all words must match).
466
+ If AND returns nothing, falls back to OR (any word matches),
467
+ ranked by how many query words each memory contains.
428
468
 
429
469
  Args:
430
470
  query: Search string.
@@ -434,19 +474,63 @@ class SQLiteBackend(BaseBackend):
434
474
  list[Memory]: Matching memories.
435
475
  """
436
476
  conn = self._get_conn()
437
- query_param = f"%{query}%"
477
+ words = query.split()
478
+
479
+ if not words:
480
+ return []
481
+
482
+ cols = ["title", "summary", "content_preview", "tags"]
483
+
484
+ def _word_clause(word: str) -> str:
485
+ return "(" + " OR ".join(f"{c} LIKE ?" for c in cols) + ")"
486
+
487
+ def _word_params(word: str) -> list[str]:
488
+ pattern = f"%{word}%"
489
+ return [pattern] * len(cols)
490
+
491
+ # Try AND first: all words must match
492
+ and_clauses = [_word_clause(w) for w in words]
493
+ and_params: list[str] = []
494
+ for w in words:
495
+ and_params.extend(_word_params(w))
496
+ and_params.append(str(limit))
438
497
 
439
498
  rows = conn.execute(
440
- """
499
+ f"""
441
500
  SELECT * FROM memories
442
- WHERE title LIKE ? OR summary LIKE ? OR content_preview LIKE ?
443
- OR tags LIKE ?
501
+ WHERE {" AND ".join(and_clauses)}
444
502
  ORDER BY created_at DESC
445
503
  LIMIT ?
446
504
  """,
447
- (query_param, query_param, query_param, query_param, limit),
505
+ and_params,
448
506
  ).fetchall()
449
507
 
508
+ if not rows and len(words) > 1:
509
+ # Fall back to OR: any word matches, ranked by match count
510
+ or_clauses = [_word_clause(w) for w in words]
511
+ # Use SUM of CASE expressions to count matching words
512
+ score_expr = " + ".join(
513
+ f"CASE WHEN {_word_clause(w)} THEN 1 ELSE 0 END" for w in words
514
+ )
515
+ or_params: list[str] = []
516
+ # params for WHERE (OR)
517
+ for w in words:
518
+ or_params.extend(_word_params(w))
519
+ # params for ORDER BY score (same patterns again)
520
+ for w in words:
521
+ or_params.extend(_word_params(w))
522
+ or_params.append(str(limit))
523
+
524
+ rows = conn.execute(
525
+ f"""
526
+ SELECT * FROM memories
527
+ WHERE {" OR ".join(or_clauses)}
528
+ ORDER BY ({score_expr}) DESC, created_at DESC
529
+ LIMIT ?
530
+ """,
531
+ or_params,
532
+ ).fetchall()
533
+
450
534
  results = []
451
535
  for row in rows:
452
536
  mem = self._row_to_memory(row)
@@ -501,7 +585,7 @@ class SQLiteBackend(BaseBackend):
501
585
 
502
586
  return results
503
587
 
504
- def list_backups(self, backup_dir: Optional[str] = None) -> list[dict]:
588
+ def list_backups(self, backup_dir: str | None = None) -> list[dict]:
505
589
  """List all skmemory backup files, sorted newest first.
506
590
 
507
591
  Args:
@@ -512,10 +596,7 @@ class SQLiteBackend(BaseBackend):
512
596
  list[dict]: Backup entries, newest first. Each entry has:
513
597
  ``path``, ``name``, ``size_bytes``, ``date``.
514
598
  """
515
- if backup_dir is None:
516
- bdir = self.base_path.parent / "backups"
517
- else:
518
- bdir = Path(backup_dir)
599
+ bdir = self.base_path.parent / "backups" if backup_dir is None else Path(backup_dir)
519
600
 
520
601
  if not bdir.exists():
521
602
  return []
@@ -532,7 +613,7 @@ class SQLiteBackend(BaseBackend):
532
613
  )
533
614
  return entries
534
615
 
535
- def prune_backups(self, keep: int = 7, backup_dir: Optional[str] = None) -> list[str]:
616
+ def prune_backups(self, keep: int = 7, backup_dir: str | None = None) -> list[str]:
536
617
  """Delete oldest backups, retaining only the N most recent.
537
618
 
538
619
  Args:
@@ -554,7 +635,7 @@ class SQLiteBackend(BaseBackend):
554
635
  pass
555
636
  return deleted
556
637
 
557
- def export_all(self, output_path: Optional[str] = None) -> str:
638
+ def export_all(self, output_path: str | None = None) -> str:
558
639
  """Export all memories as a single JSON file for backup.
559
640
 
560
641
  Reads every JSON file on disk and bundles them into one
@@ -591,8 +672,10 @@ class SQLiteBackend(BaseBackend):
591
672
  except (json.JSONDecodeError, Exception):
592
673
  continue
593
674
 
675
+ from datetime import datetime as _dt
676
+ from datetime import timezone as _tz
677
+
594
678
  from .. import __version__
595
- from datetime import datetime as _dt, timezone as _tz
596
679
 
597
680
  payload = {
598
681
  "skmemory_version": __version__,
@@ -13,7 +13,7 @@ File format on disk (per memory file):
13
13
  SKMV1 || salt(16) || nonce(12) || AES-GCM(json_bytes) || tag(16)
14
14
 
15
15
  Usage:
16
- backend = VaultedSQLiteBackend(passphrase="sovereign-key")
16
+ backend = VaultedSQLiteBackend(passphrase="EXAMPLE-DO-NOT-USE")
17
17
  store = MemoryStore(primary=backend, use_sqlite=False)
18
18
  mem = store.snapshot("title", "content")
19
19
  recalled = store.recall(mem.id) # transparent decrypt
@@ -25,12 +25,10 @@ import json
25
25
  import sqlite3
26
26
  from datetime import date, datetime, timezone
27
27
  from pathlib import Path
28
- from typing import Optional
29
28
 
30
29
  from ..models import Memory, MemoryLayer
31
30
  from ..vault import VAULT_HEADER, MemoryVault
32
- from .sqlite_backend import SQLiteBackend
33
- from .sqlite_backend import DEFAULT_BASE_PATH
31
+ from .sqlite_backend import DEFAULT_BASE_PATH, SQLiteBackend
34
32
 
35
33
 
36
34
  class VaultedSQLiteBackend(SQLiteBackend):
@@ -50,7 +48,7 @@ class VaultedSQLiteBackend(SQLiteBackend):
50
48
 
51
49
  Example::
52
50
 
53
- backend = VaultedSQLiteBackend(passphrase="my-secret")
51
+ backend = VaultedSQLiteBackend(passphrase="EXAMPLE-DO-NOT-USE")
54
52
  store = MemoryStore(primary=backend, use_sqlite=False)
55
53
  m = store.snapshot("Private thought", "End-to-end encrypted on disk")
56
54
  r = store.recall(m.id) # decrypted on the fly
@@ -83,7 +81,7 @@ class VaultedSQLiteBackend(SQLiteBackend):
83
81
  self._index_memory(memory, path)
84
82
  return memory.id
85
83
 
86
- def load(self, memory_id: str) -> Optional[Memory]:
84
+ def load(self, memory_id: str) -> Memory | None:
87
85
  """Decrypt and parse a memory file.
88
86
 
89
87
  Handles both encrypted (``SKMV1`` header) and plaintext files
@@ -100,7 +98,7 @@ class VaultedSQLiteBackend(SQLiteBackend):
100
98
  return None
101
99
  return self._read_memory_file(path)
102
100
 
103
- def _row_to_memory(self, row: sqlite3.Row) -> Optional[Memory]:
101
+ def _row_to_memory(self, row: sqlite3.Row) -> Memory | None:
104
102
  """Load a full Memory object, decrypting if needed.
105
103
 
106
104
  Args:
@@ -143,7 +141,7 @@ class VaultedSQLiteBackend(SQLiteBackend):
143
141
  continue
144
142
  return count
145
143
 
146
- def export_all(self, output_path: Optional[str] = None) -> str:
144
+ def export_all(self, output_path: str | None = None) -> str:
147
145
  """Export all memories (decrypted) to a JSON backup file.
148
146
 
149
147
  Args:
@@ -267,7 +265,7 @@ class VaultedSQLiteBackend(SQLiteBackend):
267
265
  # Internal
268
266
  # ------------------------------------------------------------------
269
267
 
270
- def _read_memory_file(self, path: Path) -> Optional[Memory]:
268
+ def _read_memory_file(self, path: Path) -> Memory | None:
271
269
  """Read a file and parse to Memory, decrypting if needed.
272
270
 
273
271
  Args: