@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
|
@@ -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) ->
|
|
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) ->
|
|
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:
|
|
128
|
-
tags:
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
results.append(Memory(**data))
|
|
187
|
+
scored.append((hits, mem))
|
|
185
188
|
except (json.JSONDecodeError, Exception):
|
|
186
189
|
continue
|
|
187
190
|
|
|
188
|
-
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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:
|
|
279
|
-
tags:
|
|
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
|
|
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:
|
|
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) ->
|
|
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": [
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
326
|
-
tags:
|
|
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:
|
|
370
|
-
tags:
|
|
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 == "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
443
|
-
OR tags LIKE ?
|
|
501
|
+
WHERE {" AND ".join(and_clauses)}
|
|
444
502
|
ORDER BY created_at DESC
|
|
445
503
|
LIMIT ?
|
|
446
504
|
""",
|
|
447
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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="
|
|
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="
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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:
|