@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.
Files changed (87) hide show
  1. package/.github/workflows/ci.yml +39 -3
  2. package/.github/workflows/publish.yml +13 -6
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +101 -19
  5. package/CHANGELOG.md +153 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +419 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/index.js +6 -5
  12. package/openclaw-plugin/openclaw.plugin.json +10 -0
  13. package/openclaw-plugin/src/index.ts +255 -0
  14. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  15. package/package.json +1 -1
  16. package/pyproject.toml +29 -9
  17. package/requirements.txt +10 -2
  18. package/seeds/cloud9-opus.seed.json +7 -7
  19. package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
  20. package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
  21. package/seeds/lumina-kingdom-founding.seed.json +47 -0
  22. package/seeds/lumina-pma-signed.seed.json +46 -0
  23. package/seeds/lumina-singular-achievement.seed.json +46 -0
  24. package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
  25. package/seeds/plant-kingdom-journal.py +203 -0
  26. package/seeds/plant-lumina-seeds.py +280 -0
  27. package/skill.yaml +46 -0
  28. package/skmemory/HA.md +296 -0
  29. package/skmemory/__init__.py +12 -1
  30. package/skmemory/agents.py +233 -0
  31. package/skmemory/ai_client.py +40 -0
  32. package/skmemory/anchor.py +4 -2
  33. package/skmemory/backends/__init__.py +11 -4
  34. package/skmemory/backends/file_backend.py +2 -1
  35. package/skmemory/backends/skgraph_backend.py +608 -0
  36. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
  37. package/skmemory/backends/sqlite_backend.py +122 -51
  38. package/skmemory/backends/vaulted_backend.py +286 -0
  39. package/skmemory/cli.py +1238 -29
  40. package/skmemory/config.py +173 -0
  41. package/skmemory/context_loader.py +335 -0
  42. package/skmemory/endpoint_selector.py +386 -0
  43. package/skmemory/fortress.py +685 -0
  44. package/skmemory/graph_queries.py +238 -0
  45. package/skmemory/importers/__init__.py +9 -1
  46. package/skmemory/importers/telegram.py +351 -43
  47. package/skmemory/importers/telegram_api.py +488 -0
  48. package/skmemory/journal.py +4 -2
  49. package/skmemory/lovenote.py +4 -2
  50. package/skmemory/mcp_server.py +706 -0
  51. package/skmemory/models.py +41 -0
  52. package/skmemory/openclaw.py +8 -8
  53. package/skmemory/predictive.py +232 -0
  54. package/skmemory/promotion.py +524 -0
  55. package/skmemory/register.py +454 -0
  56. package/skmemory/register_mcp.py +197 -0
  57. package/skmemory/ritual.py +121 -47
  58. package/skmemory/seeds.py +257 -8
  59. package/skmemory/setup_wizard.py +920 -0
  60. package/skmemory/sharing.py +402 -0
  61. package/skmemory/soul.py +71 -20
  62. package/skmemory/steelman.py +250 -263
  63. package/skmemory/store.py +271 -60
  64. package/skmemory/vault.py +228 -0
  65. package/tests/integration/__init__.py +0 -0
  66. package/tests/integration/conftest.py +233 -0
  67. package/tests/integration/test_cross_backend.py +355 -0
  68. package/tests/integration/test_skgraph_live.py +424 -0
  69. package/tests/integration/test_skvector_live.py +369 -0
  70. package/tests/test_backup_rotation.py +327 -0
  71. package/tests/test_cli.py +6 -6
  72. package/tests/test_endpoint_selector.py +801 -0
  73. package/tests/test_fortress.py +255 -0
  74. package/tests/test_fortress_hardening.py +444 -0
  75. package/tests/test_openclaw.py +5 -2
  76. package/tests/test_predictive.py +237 -0
  77. package/tests/test_promotion.py +340 -0
  78. package/tests/test_ritual.py +4 -4
  79. package/tests/test_seeds.py +96 -0
  80. package/tests/test_setup.py +835 -0
  81. package/tests/test_sharing.py +250 -0
  82. package/tests/test_skgraph_backend.py +667 -0
  83. package/tests/test_skvector_backend.py +326 -0
  84. package/tests/test_steelman.py +5 -5
  85. package/tests/test_store_graph_integration.py +245 -0
  86. package/tests/test_vault.py +186 -0
  87. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -1,12 +1,12 @@
1
1
  """
2
- Qdrant vector search backend (Level 2).
2
+ SKVector — semantic vector search backend (Level 1).
3
3
 
4
- Enables semantic memory recall: instead of exact text matching,
5
- find memories by *meaning*. "That conversation where we felt connected"
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 qdrant-client sentence-transformers
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
- class QdrantBackend(BaseBackend):
32
- """Qdrant-powered semantic memory search.
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
- Stores memory embeddings in Qdrant for vector similarity search.
35
- Falls back gracefully if Qdrant or the embedding model is unavailable.
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: Qdrant server URL (default: localhost:6333).
39
- api_key: API key for Qdrant Cloud.
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 qdrant-client")
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 sentence-transformers"
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
- logger.warning("Qdrant initialization failed: %s", e)
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=memory.content_hash(),
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 scrolling for it.
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 something was deleted.
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
- from qdrant_client.models import FieldCondition, Filter, MatchValue
223
-
224
- # Reason: Qdrant doesn't support delete-by-payload natively,
225
- # so we scroll to find the point ID then delete by point ID.
226
- results = self._client.scroll(
227
- collection_name=self.collection,
228
- scroll_filter=Filter(
229
- must=[
230
- FieldCondition(
231
- key="memory_json",
232
- match=MatchValue(value=memory_id),
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": "QdrantBackend",
346
- "error": "Not initialized (missing dependencies or connection failed)",
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": "QdrantBackend",
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": "QdrantBackend",
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 = os.path.expanduser("~/.skmemory/memories")
37
+ DEFAULT_BASE_PATH = str(SKMEMORY_HOME)
37
38
 
38
39
  _SCHEMA = """
39
40
  CREATE TABLE IF NOT EXISTS memories (
40
- id TEXT PRIMARY KEY,
41
- title TEXT NOT NULL,
42
- layer TEXT NOT NULL,
43
- role TEXT NOT NULL DEFAULT 'general',
44
- tags TEXT NOT NULL DEFAULT '',
45
- source TEXT NOT NULL DEFAULT 'manual',
46
- source_ref TEXT NOT NULL DEFAULT '',
47
- summary TEXT NOT NULL DEFAULT '',
48
- content_preview TEXT NOT NULL DEFAULT '',
49
- emotional_intensity REAL NOT NULL DEFAULT 0.0,
50
- emotional_valence REAL NOT NULL DEFAULT 0.0,
51
- emotional_labels TEXT NOT NULL DEFAULT '',
52
- cloud9_achieved INTEGER NOT NULL DEFAULT 0,
53
- parent_id TEXT,
54
- related_ids TEXT NOT NULL DEFAULT '',
55
- created_at TEXT NOT NULL,
56
- updated_at TEXT NOT NULL,
57
- file_path TEXT NOT NULL,
58
- content_hash TEXT NOT NULL DEFAULT ''
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
- r for r in neighbor["related_ids"].split(",") if r
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
- ``~/.skmemory/backups/skmemory-backup-YYYY-MM-DD.json``.
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
- json.dumps(payload, indent=2, default=str), encoding="utf-8"
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"]: