@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.
Files changed (127) hide show
  1. package/.github/workflows/ci.yml +40 -4
  2. package/.github/workflows/publish.yml +11 -5
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +399 -19
  5. package/CHANGELOG.md +179 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +425 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/examples/stignore-agent.example +59 -0
  12. package/examples/stignore-root.example +62 -0
  13. package/index.js +6 -5
  14. package/openclaw-plugin/openclaw.plugin.json +10 -0
  15. package/openclaw-plugin/package.json +2 -1
  16. package/openclaw-plugin/src/index.js +527 -230
  17. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  18. package/package.json +1 -1
  19. package/pyproject.toml +32 -9
  20. package/requirements.txt +10 -2
  21. package/scripts/dream-rescue.py +179 -0
  22. package/scripts/memory-cleanup.py +313 -0
  23. package/scripts/recover-missing.py +180 -0
  24. package/scripts/skcapstone-backup.sh +44 -0
  25. package/seeds/cloud9-lumina.seed.json +6 -4
  26. package/seeds/cloud9-opus.seed.json +13 -11
  27. package/seeds/courage.seed.json +9 -2
  28. package/seeds/curiosity.seed.json +9 -2
  29. package/seeds/grief.seed.json +9 -2
  30. package/seeds/joy.seed.json +9 -2
  31. package/seeds/love.seed.json +9 -2
  32. package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
  33. package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
  34. package/seeds/lumina-kingdom-founding.seed.json +49 -0
  35. package/seeds/lumina-pma-signed.seed.json +48 -0
  36. package/seeds/lumina-singular-achievement.seed.json +48 -0
  37. package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
  38. package/seeds/plant-kingdom-journal.py +203 -0
  39. package/seeds/plant-lumina-seeds.py +280 -0
  40. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  41. package/seeds/sovereignty.seed.json +9 -2
  42. package/seeds/trust.seed.json +9 -2
  43. package/skill.yaml +46 -0
  44. package/skmemory/HA.md +296 -0
  45. package/skmemory/__init__.py +25 -11
  46. package/skmemory/agents.py +233 -0
  47. package/skmemory/ai_client.py +46 -17
  48. package/skmemory/anchor.py +9 -11
  49. package/skmemory/audience.py +278 -0
  50. package/skmemory/backends/__init__.py +11 -4
  51. package/skmemory/backends/base.py +3 -4
  52. package/skmemory/backends/file_backend.py +19 -13
  53. package/skmemory/backends/skgraph_backend.py +596 -0
  54. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
  55. package/skmemory/backends/sqlite_backend.py +226 -72
  56. package/skmemory/backends/vaulted_backend.py +284 -0
  57. package/skmemory/cli.py +1345 -68
  58. package/skmemory/config.py +171 -0
  59. package/skmemory/context_loader.py +333 -0
  60. package/skmemory/data/audience_config.json +60 -0
  61. package/skmemory/endpoint_selector.py +391 -0
  62. package/skmemory/febs.py +225 -0
  63. package/skmemory/fortress.py +675 -0
  64. package/skmemory/graph_queries.py +238 -0
  65. package/skmemory/hooks/__init__.py +18 -0
  66. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  67. package/skmemory/hooks/pre-compact-save.sh +81 -0
  68. package/skmemory/hooks/session-end-save.sh +103 -0
  69. package/skmemory/hooks/session-start-ritual.sh +104 -0
  70. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  71. package/skmemory/importers/__init__.py +9 -1
  72. package/skmemory/importers/telegram.py +384 -47
  73. package/skmemory/importers/telegram_api.py +580 -0
  74. package/skmemory/journal.py +7 -9
  75. package/skmemory/lovenote.py +8 -13
  76. package/skmemory/mcp_server.py +859 -0
  77. package/skmemory/models.py +51 -8
  78. package/skmemory/openclaw.py +20 -28
  79. package/skmemory/post_install.py +86 -0
  80. package/skmemory/predictive.py +236 -0
  81. package/skmemory/promotion.py +548 -0
  82. package/skmemory/quadrants.py +100 -24
  83. package/skmemory/register.py +580 -0
  84. package/skmemory/register_mcp.py +196 -0
  85. package/skmemory/ritual.py +224 -59
  86. package/skmemory/seeds.py +255 -11
  87. package/skmemory/setup_wizard.py +908 -0
  88. package/skmemory/sharing.py +408 -0
  89. package/skmemory/soul.py +98 -28
  90. package/skmemory/steelman.py +273 -260
  91. package/skmemory/store.py +411 -78
  92. package/skmemory/synthesis.py +634 -0
  93. package/skmemory/vault.py +225 -0
  94. package/tests/conftest.py +46 -0
  95. package/tests/integration/__init__.py +0 -0
  96. package/tests/integration/conftest.py +233 -0
  97. package/tests/integration/test_cross_backend.py +350 -0
  98. package/tests/integration/test_skgraph_live.py +420 -0
  99. package/tests/integration/test_skvector_live.py +366 -0
  100. package/tests/test_ai_client.py +1 -4
  101. package/tests/test_audience.py +233 -0
  102. package/tests/test_backup_rotation.py +318 -0
  103. package/tests/test_cli.py +6 -6
  104. package/tests/test_endpoint_selector.py +839 -0
  105. package/tests/test_export_import.py +4 -10
  106. package/tests/test_file_backend.py +0 -1
  107. package/tests/test_fortress.py +256 -0
  108. package/tests/test_fortress_hardening.py +441 -0
  109. package/tests/test_openclaw.py +6 -6
  110. package/tests/test_predictive.py +237 -0
  111. package/tests/test_promotion.py +347 -0
  112. package/tests/test_quadrants.py +11 -5
  113. package/tests/test_ritual.py +22 -18
  114. package/tests/test_seeds.py +97 -7
  115. package/tests/test_setup.py +950 -0
  116. package/tests/test_sharing.py +257 -0
  117. package/tests/test_skgraph_backend.py +660 -0
  118. package/tests/test_skvector_backend.py +326 -0
  119. package/tests/test_soul.py +1 -3
  120. package/tests/test_sqlite_backend.py +8 -17
  121. package/tests/test_steelman.py +7 -8
  122. package/tests/test_store.py +0 -2
  123. package/tests/test_store_graph_integration.py +245 -0
  124. package/tests/test_synthesis.py +275 -0
  125. package/tests/test_telegram_import.py +39 -15
  126. package/tests/test_vault.py +187 -0
  127. 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 ..models import EmotionalSnapshot, Memory, MemoryLayer
31
+ from ..config import SKMEMORY_HOME
32
+ from ..models import Memory, MemoryLayer
34
33
  from .base import BaseBackend
35
34
 
36
- DEFAULT_BASE_PATH = os.path.expanduser("~/.skmemory/memories")
35
+ DEFAULT_BASE_PATH = str(SKMEMORY_HOME / "memory")
37
36
 
38
37
  _SCHEMA = """
39
38
  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 ''
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: Optional[sqlite3.Connection] = None
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) -> Optional[Path]:
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) -> Optional[Memory]:
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) -> Optional[Memory]:
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: Optional[MemoryLayer] = None,
301
- tags: Optional[list[str]] = None,
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: Optional[MemoryLayer] = None,
346
- tags: Optional[list[str]] = None,
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 == "emotional_intensity":
436
+ if order_by == "recency_weighted_intensity":
437
+ # Combine intensity with recency: recent high-intensity memories
438
+ # score higher than old high-intensity ones.
439
+ # julianday('now') - julianday(created_at) gives days ago.
440
+ # Decay: halve the recency bonus every 7 days.
441
+ order = (
442
+ "(emotional_intensity + "
443
+ "CASE WHEN julianday('now') - julianday(created_at) < 1 THEN 5.0 "
444
+ "WHEN julianday('now') - julianday(created_at) < 3 THEN 3.0 "
445
+ "WHEN julianday('now') - julianday(created_at) < 7 THEN 1.5 "
446
+ "ELSE 0.0 END) DESC"
447
+ )
448
+ elif order_by == "emotional_intensity":
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
- Falls back to full file scan only if the index doesn't find matches.
465
+ For multi-word queries, tries AND first (all words must match).
466
+ If AND returns nothing, falls back to OR (any word matches),
467
+ ranked by how many query words each memory contains.
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
- query_param = f"%{query}%"
477
+ words = query.split()
478
+
479
+ if not words:
480
+ return []
481
+
482
+ cols = ["title", "summary", "content_preview", "tags"]
483
+
484
+ def _word_clause(word: str) -> str:
485
+ return "(" + " OR ".join(f"{c} LIKE ?" for c in cols) + ")"
486
+
487
+ def _word_params(word: str) -> list[str]:
488
+ pattern = f"%{word}%"
489
+ return [pattern] * len(cols)
490
+
491
+ # Try AND first: all words must match
492
+ and_clauses = [_word_clause(w) for w in words]
493
+ and_params: list[str] = []
494
+ for w in words:
495
+ and_params.extend(_word_params(w))
496
+ and_params.append(str(limit))
414
497
 
415
498
  rows = conn.execute(
416
- """
499
+ f"""
417
500
  SELECT * FROM memories
418
- WHERE title LIKE ? OR summary LIKE ? OR content_preview LIKE ?
419
- OR tags LIKE ?
501
+ WHERE {" AND ".join(and_clauses)}
420
502
  ORDER BY created_at DESC
421
503
  LIMIT ?
422
504
  """,
423
- (query_param, query_param, query_param, query_param, limit),
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
- 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
- )
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 export_all(self, output_path: Optional[str] = None) -> str:
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
- ``~/.skmemory/backups/skmemory-backup-YYYY-MM-DD.json``.
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
- json.dumps(payload, indent=2, default=str), encoding="utf-8"
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"]: