@smilintux/skmemory 0.5.0 → 0.7.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 +39 -3
- package/.github/workflows/publish.yml +13 -6
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +101 -19
- package/CHANGELOG.md +153 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +419 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/src/index.ts +255 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +29 -9
- package/requirements.txt +10 -2
- package/seeds/cloud9-opus.seed.json +7 -7
- package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
- package/seeds/lumina-kingdom-founding.seed.json +47 -0
- package/seeds/lumina-pma-signed.seed.json +46 -0
- package/seeds/lumina-singular-achievement.seed.json +46 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +12 -1
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +40 -0
- package/skmemory/anchor.py +4 -2
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/file_backend.py +2 -1
- package/skmemory/backends/skgraph_backend.py +608 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
- package/skmemory/backends/sqlite_backend.py +122 -51
- package/skmemory/backends/vaulted_backend.py +286 -0
- package/skmemory/cli.py +1238 -29
- package/skmemory/config.py +173 -0
- package/skmemory/context_loader.py +335 -0
- package/skmemory/endpoint_selector.py +386 -0
- package/skmemory/fortress.py +685 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +351 -43
- package/skmemory/importers/telegram_api.py +488 -0
- package/skmemory/journal.py +4 -2
- package/skmemory/lovenote.py +4 -2
- package/skmemory/mcp_server.py +706 -0
- package/skmemory/models.py +41 -0
- package/skmemory/openclaw.py +8 -8
- package/skmemory/predictive.py +232 -0
- package/skmemory/promotion.py +524 -0
- package/skmemory/register.py +454 -0
- package/skmemory/register_mcp.py +197 -0
- package/skmemory/ritual.py +121 -47
- package/skmemory/seeds.py +257 -8
- package/skmemory/setup_wizard.py +920 -0
- package/skmemory/sharing.py +402 -0
- package/skmemory/soul.py +71 -20
- package/skmemory/steelman.py +250 -263
- package/skmemory/store.py +271 -60
- package/skmemory/vault.py +228 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +355 -0
- package/tests/integration/test_skgraph_live.py +424 -0
- package/tests/integration/test_skvector_live.py +369 -0
- package/tests/test_backup_rotation.py +327 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +801 -0
- package/tests/test_fortress.py +255 -0
- package/tests/test_fortress_hardening.py +444 -0
- package/tests/test_openclaw.py +5 -2
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +340 -0
- package/tests/test_ritual.py +4 -4
- package/tests/test_seeds.py +96 -0
- package/tests/test_setup.py +835 -0
- package/tests/test_sharing.py +250 -0
- package/tests/test_skgraph_backend.py +667 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_steelman.py +5 -5
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_vault.py +186 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
SKVector — semantic vector search backend (Level 1).
|
|
3
3
|
|
|
4
|
-
Enables semantic memory recall: instead of exact text
|
|
5
|
-
find memories by *meaning*. "That conversation where we felt
|
|
6
|
-
finds the right memory even if those exact words aren't in it.
|
|
4
|
+
Powered by Qdrant. Enables semantic memory recall: instead of exact text
|
|
5
|
+
matching, find memories by *meaning*. "That conversation where we felt
|
|
6
|
+
connected" finds the right memory even if those exact words aren't in it.
|
|
7
7
|
|
|
8
8
|
Requires:
|
|
9
|
-
pip install
|
|
9
|
+
pip install skmemory[skvector]
|
|
10
10
|
|
|
11
11
|
Qdrant free tier: 1GB storage, 256MB RAM -- enough for thousands of memories.
|
|
12
12
|
SaaS endpoint: https://cloud.qdrant.io (free cluster available).
|
|
@@ -14,6 +14,7 @@ SaaS endpoint: https://cloud.qdrant.io (free cluster available).
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import hashlib
|
|
17
18
|
import json
|
|
18
19
|
import logging
|
|
19
20
|
from typing import Optional
|
|
@@ -28,15 +29,36 @@ EMBEDDING_MODEL = "all-MiniLM-L6-v2"
|
|
|
28
29
|
VECTOR_DIM = 384
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
"""
|
|
32
|
+
def _extract_status_code(exc: Exception, unexpected_cls: type | None) -> int | None:
|
|
33
|
+
"""Pull an HTTP status code from a qdrant-client exception.
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
Works across qdrant-client versions: checks ``status_code`` attr first,
|
|
36
|
+
then falls back to the string representation for patterns like ``401``.
|
|
37
|
+
"""
|
|
38
|
+
code = getattr(exc, "status_code", None)
|
|
39
|
+
if code is not None:
|
|
40
|
+
return int(code)
|
|
41
|
+
if unexpected_cls is not None and isinstance(exc, unexpected_cls):
|
|
42
|
+
code = getattr(exc, "status_code", None)
|
|
43
|
+
if code is not None:
|
|
44
|
+
return int(code)
|
|
45
|
+
text = str(exc)
|
|
46
|
+
for candidate in (401, 403):
|
|
47
|
+
if str(candidate) in text:
|
|
48
|
+
return candidate
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SKVectorBackend(BaseBackend):
|
|
53
|
+
"""SKVector — semantic memory search (powered by Qdrant).
|
|
54
|
+
|
|
55
|
+
Stores memory embeddings for vector similarity search.
|
|
56
|
+
Falls back gracefully if the vector engine or the embedding model
|
|
57
|
+
is unavailable.
|
|
36
58
|
|
|
37
59
|
Args:
|
|
38
|
-
url:
|
|
39
|
-
api_key: API key for
|
|
60
|
+
url: SKVector server URL (default: localhost:6333).
|
|
61
|
+
api_key: API key for cloud-hosted SKVector.
|
|
40
62
|
collection: Collection name (default: 'skmemory').
|
|
41
63
|
embedding_model: Sentence-transformers model name.
|
|
42
64
|
"""
|
|
@@ -55,6 +77,7 @@ class QdrantBackend(BaseBackend):
|
|
|
55
77
|
self._client = None
|
|
56
78
|
self._embedder = None
|
|
57
79
|
self._initialized = False
|
|
80
|
+
self._last_error: str | None = None
|
|
58
81
|
|
|
59
82
|
def _ensure_initialized(self) -> bool:
|
|
60
83
|
"""Lazy-initialize Qdrant client and embedding model.
|
|
@@ -69,7 +92,7 @@ class QdrantBackend(BaseBackend):
|
|
|
69
92
|
from qdrant_client import QdrantClient
|
|
70
93
|
from qdrant_client.models import Distance, VectorParams
|
|
71
94
|
except ImportError:
|
|
72
|
-
logger.warning("qdrant-client not installed: pip install
|
|
95
|
+
logger.warning("qdrant-client not installed: pip install skmemory[skvector]")
|
|
73
96
|
return False
|
|
74
97
|
|
|
75
98
|
try:
|
|
@@ -77,10 +100,19 @@ class QdrantBackend(BaseBackend):
|
|
|
77
100
|
except ImportError:
|
|
78
101
|
logger.warning(
|
|
79
102
|
"sentence-transformers not installed: "
|
|
80
|
-
"pip install
|
|
103
|
+
"pip install skmemory[skvector]"
|
|
81
104
|
)
|
|
82
105
|
return False
|
|
83
106
|
|
|
107
|
+
try:
|
|
108
|
+
from qdrant_client.http.exceptions import (
|
|
109
|
+
ResponseHandlingException,
|
|
110
|
+
UnexpectedResponse,
|
|
111
|
+
)
|
|
112
|
+
except ImportError:
|
|
113
|
+
UnexpectedResponse = None
|
|
114
|
+
ResponseHandlingException = None
|
|
115
|
+
|
|
84
116
|
try:
|
|
85
117
|
self._client = QdrantClient(url=self.url, api_key=self.api_key)
|
|
86
118
|
collections = [c.name for c in self._client.get_collections().collections]
|
|
@@ -100,7 +132,20 @@ class QdrantBackend(BaseBackend):
|
|
|
100
132
|
return True
|
|
101
133
|
|
|
102
134
|
except Exception as e:
|
|
103
|
-
|
|
135
|
+
status = _extract_status_code(e, UnexpectedResponse)
|
|
136
|
+
if status in (401, 403):
|
|
137
|
+
hint = (
|
|
138
|
+
"SKVector authentication failed (HTTP %d). "
|
|
139
|
+
"Check your API key:\n"
|
|
140
|
+
" - CLI: --skvector-key YOUR_KEY\n"
|
|
141
|
+
" - Env: SKMEMORY_SKVECTOR_KEY=YOUR_KEY\n"
|
|
142
|
+
" - Code: SKVectorBackend(url=..., api_key='YOUR_KEY')"
|
|
143
|
+
)
|
|
144
|
+
logger.error(hint, status)
|
|
145
|
+
self._last_error = hint % status
|
|
146
|
+
else:
|
|
147
|
+
logger.warning("SKVector initialization failed: %s", e)
|
|
148
|
+
self._last_error = str(e)
|
|
104
149
|
return False
|
|
105
150
|
|
|
106
151
|
def _embed(self, text: str) -> list[float]:
|
|
@@ -155,8 +200,11 @@ class QdrantBackend(BaseBackend):
|
|
|
155
200
|
if not embedding:
|
|
156
201
|
return memory.id
|
|
157
202
|
|
|
203
|
+
# Use memory.id hash as Qdrant point ID (not content_hash which
|
|
204
|
+
# would collide if two memories have identical content).
|
|
205
|
+
point_id = int(hashlib.sha256(memory.id.encode()).hexdigest()[:15], 16)
|
|
158
206
|
point = PointStruct(
|
|
159
|
-
id=
|
|
207
|
+
id=point_id,
|
|
160
208
|
vector=embedding,
|
|
161
209
|
payload=self._memory_to_payload(memory),
|
|
162
210
|
)
|
|
@@ -167,6 +215,10 @@ class QdrantBackend(BaseBackend):
|
|
|
167
215
|
)
|
|
168
216
|
return memory.id
|
|
169
217
|
|
|
218
|
+
def _id_to_point_id(self, memory_id: str) -> int:
|
|
219
|
+
"""Convert a memory ID string to a deterministic Qdrant integer point ID."""
|
|
220
|
+
return int(hashlib.sha256(memory_id.encode()).hexdigest()[:15], 16)
|
|
221
|
+
|
|
170
222
|
def load(self, memory_id: str) -> Optional[Memory]:
|
|
171
223
|
"""Retrieve a memory by ID from Qdrant.
|
|
172
224
|
|
|
@@ -175,77 +227,52 @@ class QdrantBackend(BaseBackend):
|
|
|
175
227
|
|
|
176
228
|
Returns:
|
|
177
229
|
Optional[Memory]: The memory if found.
|
|
178
|
-
|
|
179
|
-
Note:
|
|
180
|
-
Qdrant uses content hashes as point IDs, so this does
|
|
181
|
-
a scroll+filter. For direct ID lookup, use the file backend.
|
|
182
230
|
"""
|
|
183
231
|
if not self._ensure_initialized():
|
|
184
232
|
return None
|
|
185
233
|
|
|
186
|
-
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
187
|
-
|
|
188
|
-
results = self._client.scroll(
|
|
189
|
-
collection_name=self.collection,
|
|
190
|
-
scroll_filter=Filter(
|
|
191
|
-
must=[
|
|
192
|
-
FieldCondition(
|
|
193
|
-
key="memory_json",
|
|
194
|
-
match=MatchValue(value=memory_id),
|
|
195
|
-
)
|
|
196
|
-
]
|
|
197
|
-
),
|
|
198
|
-
limit=1,
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
points = results[0] if results else []
|
|
202
|
-
if not points:
|
|
203
|
-
return None
|
|
204
|
-
|
|
205
234
|
try:
|
|
235
|
+
points = self._client.retrieve(
|
|
236
|
+
collection_name=self.collection,
|
|
237
|
+
ids=[self._id_to_point_id(memory_id)],
|
|
238
|
+
with_payload=True,
|
|
239
|
+
)
|
|
240
|
+
if not points:
|
|
241
|
+
return None
|
|
206
242
|
return Memory.model_validate_json(points[0].payload["memory_json"])
|
|
207
243
|
except Exception:
|
|
208
244
|
return None
|
|
209
245
|
|
|
210
246
|
def delete(self, memory_id: str) -> bool:
|
|
211
|
-
"""Remove a memory from Qdrant by
|
|
247
|
+
"""Remove a memory from Qdrant by its deterministic point ID.
|
|
248
|
+
|
|
249
|
+
Returns False if the memory was not found.
|
|
212
250
|
|
|
213
251
|
Args:
|
|
214
252
|
memory_id: The memory identifier.
|
|
215
253
|
|
|
216
254
|
Returns:
|
|
217
|
-
bool: True if
|
|
255
|
+
bool: True if the memory existed and was deleted, False otherwise.
|
|
218
256
|
"""
|
|
219
257
|
if not self._ensure_initialized():
|
|
220
258
|
return False
|
|
221
259
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
limit=1,
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
points = results[0] if results else []
|
|
240
|
-
if not points:
|
|
260
|
+
try:
|
|
261
|
+
points = self._client.retrieve(
|
|
262
|
+
collection_name=self.collection,
|
|
263
|
+
ids=[self._id_to_point_id(memory_id)],
|
|
264
|
+
with_payload=False,
|
|
265
|
+
)
|
|
266
|
+
if not points:
|
|
267
|
+
return False
|
|
268
|
+
self._client.delete(
|
|
269
|
+
collection_name=self.collection,
|
|
270
|
+
points_selector=[self._id_to_point_id(memory_id)],
|
|
271
|
+
)
|
|
272
|
+
return True
|
|
273
|
+
except Exception:
|
|
241
274
|
return False
|
|
242
275
|
|
|
243
|
-
self._client.delete(
|
|
244
|
-
collection_name=self.collection,
|
|
245
|
-
points_selector=[points[0].id],
|
|
246
|
-
)
|
|
247
|
-
return True
|
|
248
|
-
|
|
249
276
|
def list_memories(
|
|
250
277
|
self,
|
|
251
278
|
layer: Optional[MemoryLayer] = None,
|
|
@@ -340,17 +367,20 @@ class QdrantBackend(BaseBackend):
|
|
|
340
367
|
dict: Status with connection and collection info.
|
|
341
368
|
"""
|
|
342
369
|
if not self._ensure_initialized():
|
|
370
|
+
error_msg = self._last_error or (
|
|
371
|
+
"Not initialized (missing dependencies or connection failed)"
|
|
372
|
+
)
|
|
343
373
|
return {
|
|
344
374
|
"ok": False,
|
|
345
|
-
"backend": "
|
|
346
|
-
"error":
|
|
375
|
+
"backend": "SKVectorBackend",
|
|
376
|
+
"error": error_msg,
|
|
347
377
|
}
|
|
348
378
|
|
|
349
379
|
try:
|
|
350
380
|
info = self._client.get_collection(self.collection)
|
|
351
381
|
return {
|
|
352
382
|
"ok": True,
|
|
353
|
-
"backend": "
|
|
383
|
+
"backend": "SKVectorBackend",
|
|
354
384
|
"url": self.url,
|
|
355
385
|
"collection": self.collection,
|
|
356
386
|
"points_count": info.points_count,
|
|
@@ -359,6 +389,6 @@ class QdrantBackend(BaseBackend):
|
|
|
359
389
|
except Exception as e:
|
|
360
390
|
return {
|
|
361
391
|
"ok": False,
|
|
362
|
-
"backend": "
|
|
392
|
+
"backend": "SKVectorBackend",
|
|
363
393
|
"error": str(e),
|
|
364
394
|
}
|
|
@@ -30,39 +30,68 @@ import sqlite3
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Optional
|
|
32
32
|
|
|
33
|
+
from ..config import SKMEMORY_HOME
|
|
33
34
|
from ..models import EmotionalSnapshot, Memory, MemoryLayer
|
|
34
35
|
from .base import BaseBackend
|
|
35
36
|
|
|
36
|
-
DEFAULT_BASE_PATH =
|
|
37
|
+
DEFAULT_BASE_PATH = str(SKMEMORY_HOME)
|
|
37
38
|
|
|
38
39
|
_SCHEMA = """
|
|
39
40
|
CREATE TABLE IF NOT EXISTS memories (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
title TEXT NOT NULL,
|
|
43
|
+
layer TEXT NOT NULL,
|
|
44
|
+
role TEXT NOT NULL DEFAULT 'general',
|
|
45
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
46
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
47
|
+
source_ref TEXT NOT NULL DEFAULT '',
|
|
48
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
49
|
+
content_preview TEXT NOT NULL DEFAULT '',
|
|
50
|
+
emotional_intensity REAL NOT NULL DEFAULT 0.0,
|
|
51
|
+
emotional_valence REAL NOT NULL DEFAULT 0.0,
|
|
52
|
+
emotional_labels TEXT NOT NULL DEFAULT '',
|
|
53
|
+
cloud9_achieved INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
importance REAL NOT NULL DEFAULT 0.5, -- NEW: For prioritization (0.0-1.0)
|
|
55
|
+
access_count INTEGER NOT NULL DEFAULT 0, -- NEW: LRU tracking
|
|
56
|
+
last_accessed TEXT, -- NEW: For expiration/promotion decisions
|
|
57
|
+
parent_id TEXT,
|
|
58
|
+
related_ids TEXT NOT NULL DEFAULT '',
|
|
59
|
+
created_at TEXT NOT NULL,
|
|
60
|
+
updated_at TEXT NOT NULL,
|
|
61
|
+
file_path TEXT NOT NULL,
|
|
62
|
+
content_hash TEXT NOT NULL DEFAULT ''
|
|
59
63
|
);
|
|
60
64
|
|
|
65
|
+
-- Core indexes
|
|
61
66
|
CREATE INDEX IF NOT EXISTS idx_layer ON memories(layer);
|
|
62
67
|
CREATE INDEX IF NOT EXISTS idx_created ON memories(created_at DESC);
|
|
63
68
|
CREATE INDEX IF NOT EXISTS idx_intensity ON memories(emotional_intensity DESC);
|
|
64
69
|
CREATE INDEX IF NOT EXISTS idx_source ON memories(source);
|
|
65
70
|
CREATE INDEX IF NOT EXISTS idx_parent ON memories(parent_id);
|
|
71
|
+
|
|
72
|
+
-- NEW: Date-based indexes for lazy loading
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_date_layer ON memories(DATE(created_at), layer);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_importance ON memories(importance DESC);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_accessed ON memories(last_accessed DESC);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_access_count ON memories(access_count DESC);
|
|
77
|
+
|
|
78
|
+
-- NEW: View for active context (today + recent summaries)
|
|
79
|
+
CREATE VIEW IF NOT EXISTS active_memories AS
|
|
80
|
+
SELECT
|
|
81
|
+
id, title, summary, content_preview, tags, layer, created_at,
|
|
82
|
+
importance, access_count,
|
|
83
|
+
CASE
|
|
84
|
+
WHEN DATE(created_at) = CURRENT_DATE THEN 'today'
|
|
85
|
+
WHEN DATE(created_at) = DATE('now', '-1 day') THEN 'yesterday'
|
|
86
|
+
WHEN DATE(created_at) >= DATE('now', '-7 days') THEN 'week'
|
|
87
|
+
ELSE 'historical'
|
|
88
|
+
END as context_tier
|
|
89
|
+
FROM memories
|
|
90
|
+
WHERE created_at >= DATE('now', '-30 days')
|
|
91
|
+
ORDER BY
|
|
92
|
+
context_tier,
|
|
93
|
+
importance DESC,
|
|
94
|
+
access_count DESC;
|
|
66
95
|
"""
|
|
67
96
|
|
|
68
97
|
# Reason: 150 chars is enough for an agent to decide if it needs the full memory.
|
|
@@ -131,9 +160,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
131
160
|
Optional[Path]: Path to the file if found.
|
|
132
161
|
"""
|
|
133
162
|
conn = self._get_conn()
|
|
134
|
-
row = conn.execute(
|
|
135
|
-
"SELECT file_path FROM memories WHERE id = ?", (memory_id,)
|
|
136
|
-
).fetchone()
|
|
163
|
+
row = conn.execute("SELECT file_path FROM memories WHERE id = ?", (memory_id,)).fetchone()
|
|
137
164
|
if row:
|
|
138
165
|
path = Path(row["file_path"])
|
|
139
166
|
if path.exists():
|
|
@@ -211,9 +238,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
211
238
|
"content_preview": row["content_preview"],
|
|
212
239
|
"emotional_intensity": row["emotional_intensity"],
|
|
213
240
|
"emotional_valence": row["emotional_valence"],
|
|
214
|
-
"emotional_labels": [
|
|
215
|
-
l for l in row["emotional_labels"].split(",") if l
|
|
216
|
-
],
|
|
241
|
+
"emotional_labels": [l for l in row["emotional_labels"].split(",") if l],
|
|
217
242
|
"cloud9_achieved": bool(row["cloud9_achieved"]),
|
|
218
243
|
"created_at": row["created_at"],
|
|
219
244
|
"parent_id": row["parent_id"],
|
|
@@ -328,8 +353,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
328
353
|
params.append(limit)
|
|
329
354
|
|
|
330
355
|
rows = conn.execute(
|
|
331
|
-
f"SELECT * FROM memories WHERE {where} "
|
|
332
|
-
f"ORDER BY created_at DESC LIMIT ?",
|
|
356
|
+
f"SELECT * FROM memories WHERE {where} ORDER BY created_at DESC LIMIT ?",
|
|
333
357
|
params,
|
|
334
358
|
).fetchall()
|
|
335
359
|
|
|
@@ -446,9 +470,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
446
470
|
results: list[dict] = []
|
|
447
471
|
|
|
448
472
|
# Reason: seed the frontier from the starting node's relationships
|
|
449
|
-
row = conn.execute(
|
|
450
|
-
"SELECT * FROM memories WHERE id = ?", (memory_id,)
|
|
451
|
-
).fetchone()
|
|
473
|
+
row = conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone()
|
|
452
474
|
if row is None:
|
|
453
475
|
return results
|
|
454
476
|
|
|
@@ -464,20 +486,14 @@ class SQLiteBackend(BaseBackend):
|
|
|
464
486
|
continue
|
|
465
487
|
visited.add(mid)
|
|
466
488
|
|
|
467
|
-
neighbor = conn.execute(
|
|
468
|
-
"SELECT * FROM memories WHERE id = ?", (mid,)
|
|
469
|
-
).fetchone()
|
|
489
|
+
neighbor = conn.execute("SELECT * FROM memories WHERE id = ?", (mid,)).fetchone()
|
|
470
490
|
if neighbor is None:
|
|
471
491
|
continue
|
|
472
492
|
|
|
473
493
|
results.append(self._row_to_memory_summary(neighbor))
|
|
474
494
|
|
|
475
|
-
child_related = [
|
|
476
|
-
|
|
477
|
-
]
|
|
478
|
-
next_frontier.extend(
|
|
479
|
-
r for r in child_related if r not in visited
|
|
480
|
-
)
|
|
495
|
+
child_related = [r for r in neighbor["related_ids"].split(",") if r]
|
|
496
|
+
next_frontier.extend(r for r in child_related if r not in visited)
|
|
481
497
|
if neighbor["parent_id"] and neighbor["parent_id"] not in visited:
|
|
482
498
|
next_frontier.append(neighbor["parent_id"])
|
|
483
499
|
|
|
@@ -485,28 +501,83 @@ class SQLiteBackend(BaseBackend):
|
|
|
485
501
|
|
|
486
502
|
return results
|
|
487
503
|
|
|
504
|
+
def list_backups(self, backup_dir: Optional[str] = None) -> list[dict]:
|
|
505
|
+
"""List all skmemory backup files, sorted newest first.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
backup_dir: Directory to scan. Defaults to
|
|
509
|
+
``<base_path>/../backups/``.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
list[dict]: Backup entries, newest first. Each entry has:
|
|
513
|
+
``path``, ``name``, ``size_bytes``, ``date``.
|
|
514
|
+
"""
|
|
515
|
+
if backup_dir is None:
|
|
516
|
+
bdir = self.base_path.parent / "backups"
|
|
517
|
+
else:
|
|
518
|
+
bdir = Path(backup_dir)
|
|
519
|
+
|
|
520
|
+
if not bdir.exists():
|
|
521
|
+
return []
|
|
522
|
+
|
|
523
|
+
entries = []
|
|
524
|
+
for f in sorted(bdir.glob("skmemory-backup-*.json"), reverse=True):
|
|
525
|
+
entries.append(
|
|
526
|
+
{
|
|
527
|
+
"path": str(f),
|
|
528
|
+
"name": f.name,
|
|
529
|
+
"size_bytes": f.stat().st_size,
|
|
530
|
+
"date": f.stem.replace("skmemory-backup-", ""),
|
|
531
|
+
}
|
|
532
|
+
)
|
|
533
|
+
return entries
|
|
534
|
+
|
|
535
|
+
def prune_backups(self, keep: int = 7, backup_dir: Optional[str] = None) -> list[str]:
|
|
536
|
+
"""Delete oldest backups, retaining only the N most recent.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
keep: Number of most-recent backups to keep (default: 7).
|
|
540
|
+
backup_dir: Directory to prune. Defaults to
|
|
541
|
+
``<base_path>/../backups/``.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
list[str]: Paths of the deleted backup files.
|
|
545
|
+
"""
|
|
546
|
+
backups = self.list_backups(backup_dir)
|
|
547
|
+
to_delete = backups[keep:] # list is already newest-first
|
|
548
|
+
deleted: list[str] = []
|
|
549
|
+
for entry in to_delete:
|
|
550
|
+
try:
|
|
551
|
+
Path(entry["path"]).unlink()
|
|
552
|
+
deleted.append(entry["path"])
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
return deleted
|
|
556
|
+
|
|
488
557
|
def export_all(self, output_path: Optional[str] = None) -> str:
|
|
489
558
|
"""Export all memories as a single JSON file for backup.
|
|
490
559
|
|
|
491
560
|
Reads every JSON file on disk and bundles them into one
|
|
492
561
|
git-friendly backup. One file per day by default (overwrites
|
|
493
|
-
same-day exports).
|
|
562
|
+
same-day exports). When using the default backup directory,
|
|
563
|
+
automatically prunes to keep the last 7 daily backups.
|
|
494
564
|
|
|
495
565
|
Args:
|
|
496
566
|
output_path: Where to write the backup. If None, uses
|
|
497
|
-
``~/.
|
|
567
|
+
``~/.skcapstone/backups/skmemory-backup-YYYY-MM-DD.json``
|
|
568
|
+
and triggers automatic rotation (keep last 7).
|
|
498
569
|
|
|
499
570
|
Returns:
|
|
500
571
|
str: Path to the written backup file.
|
|
501
572
|
"""
|
|
502
573
|
from datetime import date as _date
|
|
503
574
|
|
|
575
|
+
_auto_rotate = output_path is None
|
|
576
|
+
|
|
504
577
|
if output_path is None:
|
|
505
578
|
backup_dir = self.base_path.parent / "backups"
|
|
506
579
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
507
|
-
output_path = str(
|
|
508
|
-
backup_dir / f"skmemory-backup-{_date.today().isoformat()}.json"
|
|
509
|
-
)
|
|
580
|
+
output_path = str(backup_dir / f"skmemory-backup-{_date.today().isoformat()}.json")
|
|
510
581
|
|
|
511
582
|
memories: list[dict] = []
|
|
512
583
|
for layer in MemoryLayer:
|
|
@@ -531,9 +602,11 @@ class SQLiteBackend(BaseBackend):
|
|
|
531
602
|
"memories": memories,
|
|
532
603
|
}
|
|
533
604
|
|
|
534
|
-
Path(output_path).write_text(
|
|
535
|
-
|
|
536
|
-
|
|
605
|
+
Path(output_path).write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
606
|
+
|
|
607
|
+
if _auto_rotate:
|
|
608
|
+
self.prune_backups(keep=7)
|
|
609
|
+
|
|
537
610
|
return output_path
|
|
538
611
|
|
|
539
612
|
def import_backup(self, backup_path: str) -> int:
|
|
@@ -559,9 +632,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
559
632
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
560
633
|
|
|
561
634
|
if "memories" not in data or not isinstance(data["memories"], list):
|
|
562
|
-
raise ValueError(
|
|
563
|
-
"Invalid backup file: missing 'memories' array"
|
|
564
|
-
)
|
|
635
|
+
raise ValueError("Invalid backup file: missing 'memories' array")
|
|
565
636
|
|
|
566
637
|
count = 0
|
|
567
638
|
for mem_data in data["memories"]:
|