@smilintux/skmemory 0.5.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +13 -11
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
|
@@ -25,44 +25,71 @@ 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
|
-
from ..
|
|
31
|
+
from ..config import SKMEMORY_HOME
|
|
32
|
+
from ..models import Memory, MemoryLayer
|
|
34
33
|
from .base import BaseBackend
|
|
35
34
|
|
|
36
|
-
DEFAULT_BASE_PATH =
|
|
35
|
+
DEFAULT_BASE_PATH = str(SKMEMORY_HOME / "memory")
|
|
37
36
|
|
|
38
37
|
_SCHEMA = """
|
|
39
38
|
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
|
-
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
title TEXT NOT NULL,
|
|
41
|
+
layer TEXT NOT NULL,
|
|
42
|
+
role TEXT NOT NULL DEFAULT 'general',
|
|
43
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
44
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
45
|
+
source_ref TEXT NOT NULL DEFAULT '',
|
|
46
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
47
|
+
content_preview TEXT NOT NULL DEFAULT '',
|
|
48
|
+
emotional_intensity REAL NOT NULL DEFAULT 0.0,
|
|
49
|
+
emotional_valence REAL NOT NULL DEFAULT 0.0,
|
|
50
|
+
emotional_labels TEXT NOT NULL DEFAULT '',
|
|
51
|
+
cloud9_achieved INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
importance REAL NOT NULL DEFAULT 0.5, -- NEW: For prioritization (0.0-1.0)
|
|
53
|
+
access_count INTEGER NOT NULL DEFAULT 0, -- NEW: LRU tracking
|
|
54
|
+
last_accessed TEXT, -- NEW: For expiration/promotion decisions
|
|
55
|
+
parent_id TEXT,
|
|
56
|
+
related_ids TEXT NOT NULL DEFAULT '',
|
|
57
|
+
created_at TEXT NOT NULL,
|
|
58
|
+
updated_at TEXT NOT NULL,
|
|
59
|
+
file_path TEXT NOT NULL,
|
|
60
|
+
content_hash TEXT NOT NULL DEFAULT ''
|
|
59
61
|
);
|
|
60
62
|
|
|
63
|
+
-- Core indexes
|
|
61
64
|
CREATE INDEX IF NOT EXISTS idx_layer ON memories(layer);
|
|
62
65
|
CREATE INDEX IF NOT EXISTS idx_created ON memories(created_at DESC);
|
|
63
66
|
CREATE INDEX IF NOT EXISTS idx_intensity ON memories(emotional_intensity DESC);
|
|
64
67
|
CREATE INDEX IF NOT EXISTS idx_source ON memories(source);
|
|
65
68
|
CREATE INDEX IF NOT EXISTS idx_parent ON memories(parent_id);
|
|
69
|
+
|
|
70
|
+
-- NEW: Date-based indexes for lazy loading
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_date_layer ON memories(DATE(created_at), layer);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_importance ON memories(importance DESC);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_accessed ON memories(last_accessed DESC);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_access_count ON memories(access_count DESC);
|
|
75
|
+
|
|
76
|
+
-- NEW: View for active context (today + recent summaries)
|
|
77
|
+
CREATE VIEW IF NOT EXISTS active_memories AS
|
|
78
|
+
SELECT
|
|
79
|
+
id, title, summary, content_preview, tags, layer, created_at,
|
|
80
|
+
importance, access_count,
|
|
81
|
+
CASE
|
|
82
|
+
WHEN DATE(created_at) = CURRENT_DATE THEN 'today'
|
|
83
|
+
WHEN DATE(created_at) = DATE('now', '-1 day') THEN 'yesterday'
|
|
84
|
+
WHEN DATE(created_at) >= DATE('now', '-7 days') THEN 'week'
|
|
85
|
+
ELSE 'historical'
|
|
86
|
+
END as context_tier
|
|
87
|
+
FROM memories
|
|
88
|
+
WHERE created_at >= DATE('now', '-30 days')
|
|
89
|
+
ORDER BY
|
|
90
|
+
context_tier,
|
|
91
|
+
importance DESC,
|
|
92
|
+
access_count DESC;
|
|
66
93
|
"""
|
|
67
94
|
|
|
68
95
|
# Reason: 150 chars is enough for an agent to decide if it needs the full memory.
|
|
@@ -80,7 +107,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
80
107
|
self.base_path = Path(base_path)
|
|
81
108
|
self._ensure_dirs()
|
|
82
109
|
self._db_path = self.base_path / "index.db"
|
|
83
|
-
self._conn:
|
|
110
|
+
self._conn: sqlite3.Connection | None = None
|
|
84
111
|
self._ensure_db()
|
|
85
112
|
|
|
86
113
|
def _ensure_dirs(self) -> None:
|
|
@@ -105,8 +132,36 @@ class SQLiteBackend(BaseBackend):
|
|
|
105
132
|
return self._conn
|
|
106
133
|
|
|
107
134
|
def _ensure_db(self) -> None:
|
|
108
|
-
"""Initialize the database schema."""
|
|
135
|
+
"""Initialize the database schema, migrating old tables if needed."""
|
|
109
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
|
+
|
|
110
165
|
conn.executescript(_SCHEMA)
|
|
111
166
|
conn.commit()
|
|
112
167
|
|
|
@@ -121,7 +176,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
121
176
|
"""
|
|
122
177
|
return self.base_path / memory.layer.value / f"{memory.id}.json"
|
|
123
178
|
|
|
124
|
-
def _find_file(self, memory_id: str) ->
|
|
179
|
+
def _find_file(self, memory_id: str) -> Path | None:
|
|
125
180
|
"""Locate a memory file using the index first, then fallback.
|
|
126
181
|
|
|
127
182
|
Args:
|
|
@@ -131,9 +186,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
131
186
|
Optional[Path]: Path to the file if found.
|
|
132
187
|
"""
|
|
133
188
|
conn = self._get_conn()
|
|
134
|
-
row = conn.execute(
|
|
135
|
-
"SELECT file_path FROM memories WHERE id = ?", (memory_id,)
|
|
136
|
-
).fetchone()
|
|
189
|
+
row = conn.execute("SELECT file_path FROM memories WHERE id = ?", (memory_id,)).fetchone()
|
|
137
190
|
if row:
|
|
138
191
|
path = Path(row["file_path"])
|
|
139
192
|
if path.exists():
|
|
@@ -211,16 +264,14 @@ class SQLiteBackend(BaseBackend):
|
|
|
211
264
|
"content_preview": row["content_preview"],
|
|
212
265
|
"emotional_intensity": row["emotional_intensity"],
|
|
213
266
|
"emotional_valence": row["emotional_valence"],
|
|
214
|
-
"emotional_labels": [
|
|
215
|
-
l for l in row["emotional_labels"].split(",") if l
|
|
216
|
-
],
|
|
267
|
+
"emotional_labels": [lbl for lbl in row["emotional_labels"].split(",") if lbl],
|
|
217
268
|
"cloud9_achieved": bool(row["cloud9_achieved"]),
|
|
218
269
|
"created_at": row["created_at"],
|
|
219
270
|
"parent_id": row["parent_id"],
|
|
220
271
|
"related_ids": [r for r in row["related_ids"].split(",") if r],
|
|
221
272
|
}
|
|
222
273
|
|
|
223
|
-
def _row_to_memory(self, row: sqlite3.Row) ->
|
|
274
|
+
def _row_to_memory(self, row: sqlite3.Row) -> Memory | None:
|
|
224
275
|
"""Load the full Memory object from disk using the index path.
|
|
225
276
|
|
|
226
277
|
Args:
|
|
@@ -256,7 +307,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
256
307
|
self._index_memory(memory, path)
|
|
257
308
|
return memory.id
|
|
258
309
|
|
|
259
|
-
def load(self, memory_id: str) ->
|
|
310
|
+
def load(self, memory_id: str) -> Memory | None:
|
|
260
311
|
"""Load a memory by ID, using the index for fast lookup.
|
|
261
312
|
|
|
262
313
|
Args:
|
|
@@ -297,8 +348,8 @@ class SQLiteBackend(BaseBackend):
|
|
|
297
348
|
|
|
298
349
|
def list_memories(
|
|
299
350
|
self,
|
|
300
|
-
layer:
|
|
301
|
-
tags:
|
|
351
|
+
layer: MemoryLayer | None = None,
|
|
352
|
+
tags: list[str] | None = None,
|
|
302
353
|
limit: int = 50,
|
|
303
354
|
) -> list[Memory]:
|
|
304
355
|
"""List memories using the index for filtering, loading full objects.
|
|
@@ -328,8 +379,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
328
379
|
params.append(limit)
|
|
329
380
|
|
|
330
381
|
rows = conn.execute(
|
|
331
|
-
f"SELECT * FROM memories WHERE {where} "
|
|
332
|
-
f"ORDER BY created_at DESC LIMIT ?",
|
|
382
|
+
f"SELECT * FROM memories WHERE {where} ORDER BY created_at DESC LIMIT ?",
|
|
333
383
|
params,
|
|
334
384
|
).fetchall()
|
|
335
385
|
|
|
@@ -342,8 +392,8 @@ class SQLiteBackend(BaseBackend):
|
|
|
342
392
|
|
|
343
393
|
def list_summaries(
|
|
344
394
|
self,
|
|
345
|
-
layer:
|
|
346
|
-
tags:
|
|
395
|
+
layer: MemoryLayer | None = None,
|
|
396
|
+
tags: list[str] | None = None,
|
|
347
397
|
limit: int = 50,
|
|
348
398
|
min_intensity: float = 0.0,
|
|
349
399
|
order_by: str = "created_at",
|
|
@@ -383,7 +433,19 @@ class SQLiteBackend(BaseBackend):
|
|
|
383
433
|
|
|
384
434
|
where = " AND ".join(conditions) if conditions else "1=1"
|
|
385
435
|
|
|
386
|
-
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":
|
|
387
449
|
order = "emotional_intensity DESC"
|
|
388
450
|
else:
|
|
389
451
|
order = "created_at DESC"
|
|
@@ -400,7 +462,9 @@ class SQLiteBackend(BaseBackend):
|
|
|
400
462
|
def search_text(self, query: str, limit: int = 10) -> list[Memory]:
|
|
401
463
|
"""Search memories using the SQLite index (title, summary, preview).
|
|
402
464
|
|
|
403
|
-
|
|
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.
|
|
404
468
|
|
|
405
469
|
Args:
|
|
406
470
|
query: Search string.
|
|
@@ -410,19 +474,63 @@ class SQLiteBackend(BaseBackend):
|
|
|
410
474
|
list[Memory]: Matching memories.
|
|
411
475
|
"""
|
|
412
476
|
conn = self._get_conn()
|
|
413
|
-
|
|
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))
|
|
414
497
|
|
|
415
498
|
rows = conn.execute(
|
|
416
|
-
"""
|
|
499
|
+
f"""
|
|
417
500
|
SELECT * FROM memories
|
|
418
|
-
WHERE
|
|
419
|
-
OR tags LIKE ?
|
|
501
|
+
WHERE {" AND ".join(and_clauses)}
|
|
420
502
|
ORDER BY created_at DESC
|
|
421
503
|
LIMIT ?
|
|
422
504
|
""",
|
|
423
|
-
|
|
505
|
+
and_params,
|
|
424
506
|
).fetchall()
|
|
425
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
|
+
|
|
426
534
|
results = []
|
|
427
535
|
for row in rows:
|
|
428
536
|
mem = self._row_to_memory(row)
|
|
@@ -446,9 +554,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
446
554
|
results: list[dict] = []
|
|
447
555
|
|
|
448
556
|
# Reason: seed the frontier from the starting node's relationships
|
|
449
|
-
row = conn.execute(
|
|
450
|
-
"SELECT * FROM memories WHERE id = ?", (memory_id,)
|
|
451
|
-
).fetchone()
|
|
557
|
+
row = conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone()
|
|
452
558
|
if row is None:
|
|
453
559
|
return results
|
|
454
560
|
|
|
@@ -464,20 +570,14 @@ class SQLiteBackend(BaseBackend):
|
|
|
464
570
|
continue
|
|
465
571
|
visited.add(mid)
|
|
466
572
|
|
|
467
|
-
neighbor = conn.execute(
|
|
468
|
-
"SELECT * FROM memories WHERE id = ?", (mid,)
|
|
469
|
-
).fetchone()
|
|
573
|
+
neighbor = conn.execute("SELECT * FROM memories WHERE id = ?", (mid,)).fetchone()
|
|
470
574
|
if neighbor is None:
|
|
471
575
|
continue
|
|
472
576
|
|
|
473
577
|
results.append(self._row_to_memory_summary(neighbor))
|
|
474
578
|
|
|
475
|
-
child_related = [
|
|
476
|
-
|
|
477
|
-
]
|
|
478
|
-
next_frontier.extend(
|
|
479
|
-
r for r in child_related if r not in visited
|
|
480
|
-
)
|
|
579
|
+
child_related = [r for r in neighbor["related_ids"].split(",") if r]
|
|
580
|
+
next_frontier.extend(r for r in child_related if r not in visited)
|
|
481
581
|
if neighbor["parent_id"] and neighbor["parent_id"] not in visited:
|
|
482
582
|
next_frontier.append(neighbor["parent_id"])
|
|
483
583
|
|
|
@@ -485,28 +585,80 @@ class SQLiteBackend(BaseBackend):
|
|
|
485
585
|
|
|
486
586
|
return results
|
|
487
587
|
|
|
488
|
-
def
|
|
588
|
+
def list_backups(self, backup_dir: str | None = None) -> list[dict]:
|
|
589
|
+
"""List all skmemory backup files, sorted newest first.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
backup_dir: Directory to scan. Defaults to
|
|
593
|
+
``<base_path>/../backups/``.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
list[dict]: Backup entries, newest first. Each entry has:
|
|
597
|
+
``path``, ``name``, ``size_bytes``, ``date``.
|
|
598
|
+
"""
|
|
599
|
+
bdir = self.base_path.parent / "backups" if backup_dir is None else Path(backup_dir)
|
|
600
|
+
|
|
601
|
+
if not bdir.exists():
|
|
602
|
+
return []
|
|
603
|
+
|
|
604
|
+
entries = []
|
|
605
|
+
for f in sorted(bdir.glob("skmemory-backup-*.json"), reverse=True):
|
|
606
|
+
entries.append(
|
|
607
|
+
{
|
|
608
|
+
"path": str(f),
|
|
609
|
+
"name": f.name,
|
|
610
|
+
"size_bytes": f.stat().st_size,
|
|
611
|
+
"date": f.stem.replace("skmemory-backup-", ""),
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
return entries
|
|
615
|
+
|
|
616
|
+
def prune_backups(self, keep: int = 7, backup_dir: str | None = None) -> list[str]:
|
|
617
|
+
"""Delete oldest backups, retaining only the N most recent.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
keep: Number of most-recent backups to keep (default: 7).
|
|
621
|
+
backup_dir: Directory to prune. Defaults to
|
|
622
|
+
``<base_path>/../backups/``.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
list[str]: Paths of the deleted backup files.
|
|
626
|
+
"""
|
|
627
|
+
backups = self.list_backups(backup_dir)
|
|
628
|
+
to_delete = backups[keep:] # list is already newest-first
|
|
629
|
+
deleted: list[str] = []
|
|
630
|
+
for entry in to_delete:
|
|
631
|
+
try:
|
|
632
|
+
Path(entry["path"]).unlink()
|
|
633
|
+
deleted.append(entry["path"])
|
|
634
|
+
except OSError:
|
|
635
|
+
pass
|
|
636
|
+
return deleted
|
|
637
|
+
|
|
638
|
+
def export_all(self, output_path: str | None = None) -> str:
|
|
489
639
|
"""Export all memories as a single JSON file for backup.
|
|
490
640
|
|
|
491
641
|
Reads every JSON file on disk and bundles them into one
|
|
492
642
|
git-friendly backup. One file per day by default (overwrites
|
|
493
|
-
same-day exports).
|
|
643
|
+
same-day exports). When using the default backup directory,
|
|
644
|
+
automatically prunes to keep the last 7 daily backups.
|
|
494
645
|
|
|
495
646
|
Args:
|
|
496
647
|
output_path: Where to write the backup. If None, uses
|
|
497
|
-
``~/.
|
|
648
|
+
``~/.skcapstone/backups/skmemory-backup-YYYY-MM-DD.json``
|
|
649
|
+
and triggers automatic rotation (keep last 7).
|
|
498
650
|
|
|
499
651
|
Returns:
|
|
500
652
|
str: Path to the written backup file.
|
|
501
653
|
"""
|
|
502
654
|
from datetime import date as _date
|
|
503
655
|
|
|
656
|
+
_auto_rotate = output_path is None
|
|
657
|
+
|
|
504
658
|
if output_path is None:
|
|
505
659
|
backup_dir = self.base_path.parent / "backups"
|
|
506
660
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
507
|
-
output_path = str(
|
|
508
|
-
backup_dir / f"skmemory-backup-{_date.today().isoformat()}.json"
|
|
509
|
-
)
|
|
661
|
+
output_path = str(backup_dir / f"skmemory-backup-{_date.today().isoformat()}.json")
|
|
510
662
|
|
|
511
663
|
memories: list[dict] = []
|
|
512
664
|
for layer in MemoryLayer:
|
|
@@ -520,8 +672,10 @@ class SQLiteBackend(BaseBackend):
|
|
|
520
672
|
except (json.JSONDecodeError, Exception):
|
|
521
673
|
continue
|
|
522
674
|
|
|
675
|
+
from datetime import datetime as _dt
|
|
676
|
+
from datetime import timezone as _tz
|
|
677
|
+
|
|
523
678
|
from .. import __version__
|
|
524
|
-
from datetime import datetime as _dt, timezone as _tz
|
|
525
679
|
|
|
526
680
|
payload = {
|
|
527
681
|
"skmemory_version": __version__,
|
|
@@ -531,9 +685,11 @@ class SQLiteBackend(BaseBackend):
|
|
|
531
685
|
"memories": memories,
|
|
532
686
|
}
|
|
533
687
|
|
|
534
|
-
Path(output_path).write_text(
|
|
535
|
-
|
|
536
|
-
|
|
688
|
+
Path(output_path).write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
689
|
+
|
|
690
|
+
if _auto_rotate:
|
|
691
|
+
self.prune_backups(keep=7)
|
|
692
|
+
|
|
537
693
|
return output_path
|
|
538
694
|
|
|
539
695
|
def import_backup(self, backup_path: str) -> int:
|
|
@@ -559,9 +715,7 @@ class SQLiteBackend(BaseBackend):
|
|
|
559
715
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
560
716
|
|
|
561
717
|
if "memories" not in data or not isinstance(data["memories"], list):
|
|
562
|
-
raise ValueError(
|
|
563
|
-
"Invalid backup file: missing 'memories' array"
|
|
564
|
-
)
|
|
718
|
+
raise ValueError("Invalid backup file: missing 'memories' array")
|
|
565
719
|
|
|
566
720
|
count = 0
|
|
567
721
|
for mem_data in data["memories"]:
|