@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
package/skmemory/store.py CHANGED
@@ -8,11 +8,12 @@ or by search, and the polaroid comes back with everything intact.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
11
12
  from datetime import datetime, timezone
12
- from typing import Optional
13
13
 
14
14
  from .backends.base import BaseBackend
15
15
  from .backends.file_backend import FileBackend
16
+ from .backends.skgraph_backend import SKGraphBackend
16
17
  from .backends.sqlite_backend import CONTENT_PREVIEW_LENGTH, SQLiteBackend
17
18
  from .models import (
18
19
  EmotionalSnapshot,
@@ -22,23 +23,35 @@ from .models import (
22
23
  SeedMemory,
23
24
  )
24
25
 
26
+ logger = logging.getLogger("skmemory.store")
27
+
28
+ MAX_CONTENT_LENGTH = 10000
29
+ CONTENT_OVERFLOW_STRATEGY = "split" # "truncate" or "split"
30
+
25
31
 
26
32
  class MemoryStore:
27
33
  """Main entry point for all memory operations.
28
34
 
29
35
  Delegates to one or more backends. The primary backend handles
30
36
  all CRUD. A vector backend (optional) handles semantic search.
37
+ A graph backend (optional) indexes relationships for traversal.
31
38
 
32
39
  Args:
33
40
  primary: The primary storage backend (default: FileBackend).
34
- vector: Optional vector search backend (e.g., QdrantBackend).
41
+ vector: Optional vector search backend (e.g., SKVectorBackend).
42
+ graph: Optional graph backend (e.g., SKGraphBackend) for relationship indexing.
43
+ max_content_length: Max chars before overflow strategy applies (default: 10000).
44
+ content_overflow_strategy: "truncate" or "split" (default: "split").
35
45
  """
36
46
 
37
47
  def __init__(
38
48
  self,
39
- primary: Optional[BaseBackend] = None,
40
- vector: Optional[BaseBackend] = None,
49
+ primary: BaseBackend | None = None,
50
+ vector: BaseBackend | None = None,
51
+ graph: SKGraphBackend | None = None,
41
52
  use_sqlite: bool = True,
53
+ max_content_length: int = MAX_CONTENT_LENGTH,
54
+ content_overflow_strategy: str = CONTENT_OVERFLOW_STRATEGY,
42
55
  ) -> None:
43
56
  if primary is not None:
44
57
  self.primary = primary
@@ -47,6 +60,9 @@ class MemoryStore:
47
60
  else:
48
61
  self.primary = FileBackend()
49
62
  self.vector = vector
63
+ self.graph = graph
64
+ self.max_content_length = max_content_length
65
+ self.content_overflow_strategy = content_overflow_strategy
50
66
 
51
67
  def snapshot(
52
68
  self,
@@ -55,12 +71,12 @@ class MemoryStore:
55
71
  *,
56
72
  layer: MemoryLayer = MemoryLayer.SHORT,
57
73
  role: MemoryRole = MemoryRole.GENERAL,
58
- tags: Optional[list[str]] = None,
59
- emotional: Optional[EmotionalSnapshot] = None,
74
+ tags: list[str] | None = None,
75
+ emotional: EmotionalSnapshot | None = None,
60
76
  source: str = "manual",
61
77
  source_ref: str = "",
62
- related_ids: Optional[list[str]] = None,
63
- metadata: Optional[dict] = None,
78
+ related_ids: list[str] | None = None,
79
+ metadata: dict | None = None,
64
80
  ) -> Memory:
65
81
  """Take a polaroid -- capture a moment as a memory.
66
82
 
@@ -82,6 +98,30 @@ class MemoryStore:
82
98
  Returns:
83
99
  Memory: The stored memory with its assigned ID.
84
100
  """
101
+ # Handle content overflow
102
+ if len(content) > self.max_content_length:
103
+ if self.content_overflow_strategy == "split":
104
+ return self._snapshot_split(
105
+ title=title,
106
+ content=content,
107
+ layer=layer,
108
+ role=role,
109
+ tags=tags,
110
+ emotional=emotional,
111
+ source=source,
112
+ source_ref=source_ref,
113
+ related_ids=related_ids,
114
+ metadata=metadata,
115
+ )
116
+ else:
117
+ logger.info(
118
+ "Content truncated from %d to %d chars for '%s'",
119
+ len(content),
120
+ self.max_content_length,
121
+ title,
122
+ )
123
+ content = content[: self.max_content_length]
124
+
85
125
  memory = Memory(
86
126
  title=title,
87
127
  content=content,
@@ -95,18 +135,122 @@ class MemoryStore:
95
135
  metadata=metadata or {},
96
136
  )
97
137
 
138
+ memory.seal()
139
+
98
140
  self.primary.save(memory)
99
141
 
100
142
  if self.vector:
101
143
  try:
102
144
  self.vector.save(memory)
103
- except Exception:
104
- pass # Reason: vector indexing is best-effort, don't fail the write
145
+ except Exception as exc:
146
+ logger.warning("Vector indexing failed for memory %s: %s", memory.id, exc)
147
+
148
+ if self.graph:
149
+ try:
150
+ self.graph.index_memory(memory)
151
+ except Exception as exc:
152
+ logger.warning("Graph indexing failed for memory %s: %s", memory.id, exc)
105
153
 
106
154
  return memory
107
155
 
108
- def recall(self, memory_id: str) -> Optional[Memory]:
109
- """Retrieve a specific memory by ID.
156
+ def _snapshot_split(
157
+ self,
158
+ title: str,
159
+ content: str,
160
+ *,
161
+ layer: MemoryLayer = MemoryLayer.SHORT,
162
+ role: MemoryRole = MemoryRole.GENERAL,
163
+ tags: list[str] | None = None,
164
+ emotional: EmotionalSnapshot | None = None,
165
+ source: str = "manual",
166
+ source_ref: str = "",
167
+ related_ids: list[str] | None = None,
168
+ metadata: dict | None = None,
169
+ ) -> Memory:
170
+ """Split oversized content into parent (summary) + child (chunk) memories.
171
+
172
+ The parent memory contains a summary (first 200 chars) and links to
173
+ child memories via related_ids. Each child holds one chunk.
174
+
175
+ Returns:
176
+ Memory: The parent memory.
177
+ """
178
+ chunk_size = self.max_content_length
179
+ chunks = [content[i : i + chunk_size] for i in range(0, len(content), chunk_size)]
180
+
181
+ logger.info(
182
+ "Splitting '%s' (%d chars) into %d chunks",
183
+ title,
184
+ len(content),
185
+ len(chunks),
186
+ )
187
+
188
+ # Create child memories first
189
+ child_ids: list[str] = []
190
+ for i, chunk in enumerate(chunks):
191
+ child = Memory(
192
+ title=f"{title} [part {i + 1}/{len(chunks)}]",
193
+ content=chunk,
194
+ layer=layer,
195
+ role=role,
196
+ tags=(tags or []) + ["content-chunk"],
197
+ emotional=emotional or EmotionalSnapshot(),
198
+ source=source,
199
+ source_ref=source_ref,
200
+ metadata={
201
+ **(metadata or {}),
202
+ "chunk_index": i,
203
+ "chunk_total": len(chunks),
204
+ },
205
+ )
206
+ child.seal()
207
+ self.primary.save(child)
208
+ child_ids.append(child.id)
209
+
210
+ # Create parent with summary
211
+ summary = content[:200] + ("..." if len(content) > 200 else "")
212
+ all_related = (related_ids or []) + child_ids
213
+
214
+ parent = Memory(
215
+ title=title,
216
+ content=summary,
217
+ summary=summary,
218
+ layer=layer,
219
+ role=role,
220
+ tags=(tags or []) + ["content-split-parent"],
221
+ emotional=emotional or EmotionalSnapshot(),
222
+ source=source,
223
+ source_ref=source_ref,
224
+ related_ids=all_related,
225
+ metadata={
226
+ **(metadata or {}),
227
+ "split_children": child_ids,
228
+ "original_length": len(content),
229
+ },
230
+ )
231
+ parent.seal()
232
+ self.primary.save(parent)
233
+
234
+ if self.vector:
235
+ try:
236
+ self.vector.save(parent)
237
+ except Exception as exc:
238
+ logger.warning("Vector indexing failed for split parent %s: %s", parent.id, exc)
239
+
240
+ if self.graph:
241
+ try:
242
+ self.graph.index_memory(parent)
243
+ except Exception as exc:
244
+ logger.warning("Graph indexing failed for split parent %s: %s", parent.id, exc)
245
+
246
+ return parent
247
+
248
+ def recall(self, memory_id: str) -> Memory | None:
249
+ """Retrieve a specific memory by ID with integrity verification.
250
+
251
+ Automatically checks the integrity hash on recall. If the
252
+ memory has been tampered with, a warning is logged and the
253
+ memory's metadata is flagged with 'integrity_warning'.
110
254
 
111
255
  Args:
112
256
  memory_id: The memory's unique identifier.
@@ -114,7 +258,22 @@ class MemoryStore:
114
258
  Returns:
115
259
  Optional[Memory]: The memory if found.
116
260
  """
117
- return self.primary.load(memory_id)
261
+ memory = self.primary.load(memory_id)
262
+ if memory is None:
263
+ return None
264
+
265
+ if memory.integrity_hash and not memory.verify_integrity():
266
+ logger.warning(
267
+ "TAMPER ALERT: Memory %s failed integrity check! "
268
+ "Content may have been modified since storage.",
269
+ memory_id,
270
+ )
271
+ memory.metadata["integrity_warning"] = (
272
+ f"Integrity check failed at {datetime.now(timezone.utc).isoformat()}. "
273
+ "This memory may have been tampered with."
274
+ )
275
+
276
+ return memory
118
277
 
119
278
  def search(self, query: str, limit: int = 10) -> list[Memory]:
120
279
  """Search memories by text.
@@ -133,8 +292,8 @@ class MemoryStore:
133
292
  results = self.vector.search_text(query, limit=limit)
134
293
  if results:
135
294
  return results
136
- except Exception:
137
- pass # Reason: fall through to primary text search
295
+ except Exception as exc:
296
+ logger.warning("Vector search failed, falling back to text search: %s", exc)
138
297
 
139
298
  return self.primary.search_text(query, limit=limit)
140
299
 
@@ -151,14 +310,19 @@ class MemoryStore:
151
310
  if self.vector:
152
311
  try:
153
312
  self.vector.delete(memory_id)
154
- except Exception:
155
- pass
313
+ except Exception as exc:
314
+ logger.warning("Vector delete failed for memory %s: %s", memory_id, exc)
315
+ if self.graph:
316
+ try:
317
+ self.graph.remove_memory(memory_id)
318
+ except Exception as exc:
319
+ logger.warning("Graph delete failed for memory %s: %s", memory_id, exc)
156
320
  return deleted
157
321
 
158
322
  def list_memories(
159
323
  self,
160
- layer: Optional[MemoryLayer] = None,
161
- tags: Optional[list[str]] = None,
324
+ layer: MemoryLayer | None = None,
325
+ tags: list[str] | None = None,
162
326
  limit: int = 50,
163
327
  ) -> list[Memory]:
164
328
  """List memories with optional filtering.
@@ -178,7 +342,7 @@ class MemoryStore:
178
342
  memory_id: str,
179
343
  target: MemoryLayer,
180
344
  summary: str = "",
181
- ) -> Optional[Memory]:
345
+ ) -> Memory | None:
182
346
  """Promote a memory to a higher persistence tier.
183
347
 
184
348
  Creates a new memory at the target layer linked to the original.
@@ -202,32 +366,65 @@ class MemoryStore:
202
366
  if self.vector:
203
367
  try:
204
368
  self.vector.save(promoted)
205
- except Exception:
206
- pass
369
+ except Exception as exc:
370
+ logger.warning(
371
+ "Vector indexing failed for promoted memory %s: %s", promoted.id, exc
372
+ )
373
+
374
+ if self.graph:
375
+ try:
376
+ self.graph.index_memory(promoted)
377
+ except Exception as exc:
378
+ logger.warning(
379
+ "Graph indexing failed for promoted memory %s: %s", promoted.id, exc
380
+ )
207
381
 
208
382
  return promoted
209
383
 
210
- def ingest_seed(self, seed: SeedMemory) -> Memory:
384
+ def ingest_seed(self, seed: SeedMemory, *, validate: bool = True) -> Memory:
211
385
  """Import a Cloud 9 seed as a long-term memory.
212
386
 
213
387
  Converts a seed into a Memory and stores it. This is how
214
388
  seeds planted by one AI instance become retrievable memories
215
389
  for the next.
216
390
 
391
+ When *validate* is True (default), basic integrity checks run
392
+ before storage: seed_id must be non-empty and
393
+ experience_summary must contain content.
394
+
217
395
  Args:
218
396
  seed: The SeedMemory to import.
397
+ validate: Run pre-import validation (default True).
219
398
 
220
399
  Returns:
221
400
  Memory: The created long-term memory.
401
+
402
+ Raises:
403
+ ValueError: If validation is enabled and the seed is invalid.
222
404
  """
405
+ if validate:
406
+ errors: list[str] = []
407
+ if not seed.seed_id or not seed.seed_id.strip():
408
+ errors.append("seed_id is empty")
409
+ if not seed.experience_summary or not seed.experience_summary.strip():
410
+ errors.append("experience_summary is empty")
411
+ if errors:
412
+ raise ValueError(f"Seed validation failed: {'; '.join(errors)}")
413
+
223
414
  memory = seed.to_memory()
224
415
  self.primary.save(memory)
225
416
 
226
417
  if self.vector:
227
418
  try:
228
419
  self.vector.save(memory)
229
- except Exception:
230
- pass
420
+ except Exception as exc:
421
+ logger.warning("Vector indexing failed for seed memory %s: %s", memory.id, exc)
422
+
423
+ if self.graph:
424
+ try:
425
+ self.graph.index_memory(memory)
426
+ except Exception as exc:
427
+ logger.warning("Graph indexing failed for seed memory %s: %s", memory.id, exc)
231
428
 
232
429
  return memory
233
430
 
@@ -249,7 +446,7 @@ class MemoryStore:
249
446
  self,
250
447
  session_id: str,
251
448
  summary: str,
252
- emotional: Optional[EmotionalSnapshot] = None,
449
+ emotional: EmotionalSnapshot | None = None,
253
450
  ) -> Memory:
254
451
  """Compress a session's short-term memories into a single mid-term memory.
255
452
 
@@ -291,89 +488,156 @@ class MemoryStore:
291
488
 
292
489
  def load_context(
293
490
  self,
294
- max_tokens: int = 3000,
491
+ max_tokens: int = 4000,
295
492
  strongest_count: int = 5,
296
493
  recent_count: int = 5,
297
494
  include_seeds: bool = True,
298
495
  ) -> dict:
299
- """Load a token-efficient memory context for agent injection.
496
+ """Load tiered memory context for agent injection (lazy loading).
300
497
 
301
- Uses the SQLite index to pull summaries without reading full files.
302
- Designed to fit within a reasonable context window.
498
+ Uses date-based tiers per memory-architecture.md:
499
+ - Today's memories: full content (title + body)
500
+ - Yesterday's memories: summary only (title + first 2 sentences)
501
+ - Older than 2 days: reference count only
303
502
 
304
503
  Args:
305
- max_tokens: Approximate token budget (1 token ~= 4 chars).
504
+ max_tokens: Approximate token budget (default: 4000).
505
+ Uses word_count * 1.3 approximation for estimation.
306
506
  strongest_count: How many top-intensity memories to include.
307
507
  recent_count: How many recent memories to include.
308
508
  include_seeds: Whether to include seed memories.
309
509
 
310
510
  Returns:
311
- dict: Token-efficient context with summaries and metadata.
511
+ dict: Token-efficient tiered context with metadata.
312
512
  """
313
- char_budget = max_tokens * 4
314
- context: dict = {"memories": [], "seeds": [], "stats": {}}
315
- used = 0
513
+ context: dict = {
514
+ "today": [],
515
+ "yesterday": [],
516
+ "older_summary": {},
517
+ "seeds": [],
518
+ "stats": {},
519
+ }
520
+ used_tokens = 0
316
521
 
317
522
  if isinstance(self.primary, SQLiteBackend):
318
- strongest = self.primary.list_summaries(
319
- limit=strongest_count,
320
- order_by="emotional_intensity",
321
- min_intensity=3.0,
322
- )
323
- recent = self.primary.list_summaries(
324
- limit=recent_count,
325
- order_by="created_at",
326
- )
327
-
328
- seen_ids: set[str] = set()
329
- for mem in strongest + recent:
330
- if mem["id"] in seen_ids:
331
- continue
332
- seen_ids.add(mem["id"])
333
-
334
- entry_text = mem["title"] + (mem["summary"] or mem["content_preview"])
335
- entry_size = len(entry_text)
336
- if used + entry_size > char_budget:
523
+ conn = self.primary._get_conn()
524
+
525
+ # --- Tier 1: Today's memories (full content) ---
526
+ today_rows = conn.execute(
527
+ "SELECT * FROM memories WHERE DATE(created_at) = DATE('now') "
528
+ "ORDER BY importance DESC, created_at DESC LIMIT 20"
529
+ ).fetchall()
530
+
531
+ for row in today_rows:
532
+ summary_dict = self.primary._row_to_memory_summary(row)
533
+ # Include full content for today
534
+ content = summary_dict.get("summary") or summary_dict.get("content_preview") or ""
535
+ entry = {
536
+ "id": summary_dict["id"],
537
+ "title": summary_dict["title"],
538
+ "content": content,
539
+ "tags": summary_dict["tags"],
540
+ "layer": summary_dict["layer"],
541
+ "emotional_intensity": summary_dict["emotional_intensity"],
542
+ }
543
+ entry_tokens = _estimate_tokens(entry["title"] + " " + content)
544
+ if used_tokens + entry_tokens > max_tokens:
337
545
  break
338
- used += entry_size
339
- context["memories"].append(mem)
340
-
546
+ used_tokens += entry_tokens
547
+ context["today"].append(entry)
548
+
549
+ # --- Tier 2: Yesterday's memories (summary only: title + first 2 sentences) ---
550
+ yesterday_rows = conn.execute(
551
+ "SELECT * FROM memories WHERE DATE(created_at) = DATE('now', '-1 day') "
552
+ "ORDER BY importance DESC, created_at DESC LIMIT 20"
553
+ ).fetchall()
554
+
555
+ for row in yesterday_rows:
556
+ summary_dict = self.primary._row_to_memory_summary(row)
557
+ raw_text = summary_dict.get("summary") or summary_dict.get("content_preview") or ""
558
+ short_summary = _first_n_sentences(raw_text, 2)
559
+ entry = {
560
+ "id": summary_dict["id"],
561
+ "title": summary_dict["title"],
562
+ "summary": short_summary,
563
+ }
564
+ entry_tokens = _estimate_tokens(entry["title"] + " " + short_summary)
565
+ if used_tokens + entry_tokens > max_tokens:
566
+ break
567
+ used_tokens += entry_tokens
568
+ context["yesterday"].append(entry)
569
+
570
+ # --- Tier 3: Older memories (reference count only) ---
571
+ mid_count = conn.execute(
572
+ "SELECT COUNT(*) FROM memories WHERE DATE(created_at) < DATE('now', '-1 day') "
573
+ "AND layer = 'mid-term'"
574
+ ).fetchone()[0]
575
+ long_count = conn.execute(
576
+ "SELECT COUNT(*) FROM memories WHERE DATE(created_at) < DATE('now', '-1 day') "
577
+ "AND layer = 'long-term'"
578
+ ).fetchone()[0]
579
+ short_old_count = conn.execute(
580
+ "SELECT COUNT(*) FROM memories WHERE DATE(created_at) < DATE('now', '-1 day') "
581
+ "AND layer = 'short-term'"
582
+ ).fetchone()[0]
583
+
584
+ context["older_summary"] = {
585
+ "mid_term_count": mid_count,
586
+ "long_term_count": long_count,
587
+ "short_term_count": short_old_count,
588
+ "total": mid_count + long_count + short_old_count,
589
+ "hint": (
590
+ f"{mid_count} mid-term memories, {long_count} long-term memories "
591
+ "available via memory_search"
592
+ ),
593
+ }
594
+ used_tokens += _estimate_tokens(context["older_summary"]["hint"])
595
+
596
+ # --- Seeds (titles only to save tokens) ---
341
597
  if include_seeds:
342
- seeds = self.primary.list_summaries(
598
+ seed_rows = self.primary.list_summaries(
343
599
  tags=["seed"],
344
600
  limit=10,
345
601
  order_by="emotional_intensity",
346
602
  )
347
- for seed in seeds:
603
+ seen_ids = {m["id"] for m in context["today"]}
604
+ seen_ids.update(m["id"] for m in context["yesterday"])
605
+
606
+ for seed in seed_rows:
348
607
  if seed["id"] in seen_ids:
349
608
  continue
350
- entry_text = seed["title"] + seed["summary"]
351
- entry_size = len(entry_text)
352
- if used + entry_size > char_budget:
609
+ entry = {
610
+ "id": seed["id"],
611
+ "title": seed["title"],
612
+ }
613
+ entry_tokens = _estimate_tokens(seed["title"])
614
+ if used_tokens + entry_tokens > max_tokens:
353
615
  break
354
- used += entry_size
355
- context["seeds"].append(seed)
616
+ used_tokens += entry_tokens
617
+ context["seeds"].append(entry)
356
618
 
357
619
  stats = self.primary.stats()
358
620
  context["stats"] = stats
359
621
  else:
360
- # Reason: fallback for non-SQLite backends uses full objects
622
+ # Fallback for non-SQLite backends: simple recent list
361
623
  all_mems = self.primary.list_memories(limit=strongest_count + recent_count)
362
624
  for mem in all_mems:
625
+ content_text = mem.summary or mem.content[:CONTENT_PREVIEW_LENGTH]
363
626
  entry = {
364
627
  "id": mem.id,
365
628
  "title": mem.title,
366
- "summary": mem.summary or mem.content[:CONTENT_PREVIEW_LENGTH],
629
+ "summary": _first_n_sentences(content_text, 2),
367
630
  "emotional_intensity": mem.emotional.intensity,
368
631
  "layer": mem.layer.value,
369
632
  }
370
- entry_size = len(entry["title"] + entry["summary"])
371
- if used + entry_size > char_budget:
633
+ entry_tokens = _estimate_tokens(entry["title"] + " " + entry["summary"])
634
+ if used_tokens + entry_tokens > max_tokens:
372
635
  break
373
- used += entry_size
374
- context["memories"].append(entry)
636
+ used_tokens += entry_tokens
637
+ context["today"].append(entry)
375
638
 
376
- context["token_estimate"] = used // 4
639
+ context["token_estimate"] = used_tokens
640
+ context["token_budget"] = max_tokens
377
641
  return context
378
642
 
379
643
  def export_backup(self, output_path: str | None = None) -> str:
@@ -381,7 +645,7 @@ class MemoryStore:
381
645
 
382
646
  Args:
383
647
  output_path: Destination file. Defaults to
384
- ``~/.skmemory/backups/skmemory-backup-YYYY-MM-DD.json``.
648
+ ``~/.skcapstone/backups/skmemory-backup-YYYY-MM-DD.json``.
385
649
 
386
650
  Returns:
387
651
  str: Path to the written backup file.
@@ -396,9 +660,7 @@ class MemoryStore:
396
660
  temp = SQLiteBackend(base_path=str(self.primary.base_path))
397
661
  temp.reindex()
398
662
  return temp.export_all(output_path)
399
- raise RuntimeError(
400
- f"Export not supported for backend: {type(self.primary).__name__}"
401
- )
663
+ raise RuntimeError(f"Export not supported for backend: {type(self.primary).__name__}")
402
664
 
403
665
  def import_backup(self, backup_path: str) -> int:
404
666
  """Restore memories from a JSON backup file.
@@ -414,9 +676,37 @@ class MemoryStore:
414
676
  """
415
677
  if isinstance(self.primary, SQLiteBackend):
416
678
  return self.primary.import_backup(backup_path)
417
- raise RuntimeError(
418
- f"Import not supported for backend: {type(self.primary).__name__}"
419
- )
679
+ raise RuntimeError(f"Import not supported for backend: {type(self.primary).__name__}")
680
+
681
+ def list_backups(self, backup_dir: str | None = None) -> list[dict]:
682
+ """List all skmemory backup files, sorted newest first.
683
+
684
+ Args:
685
+ backup_dir: Directory to scan. Defaults to
686
+ ``~/.skcapstone/backups/``.
687
+
688
+ Returns:
689
+ list[dict]: Backup entries with ``path``, ``name``,
690
+ ``size_bytes``, and ``date`` keys.
691
+ """
692
+ if isinstance(self.primary, SQLiteBackend):
693
+ return self.primary.list_backups(backup_dir)
694
+ return []
695
+
696
+ def prune_backups(self, keep: int = 7, backup_dir: str | None = None) -> list[str]:
697
+ """Delete oldest backups, keeping only the N most recent.
698
+
699
+ Args:
700
+ keep: Number of backups to retain (default: 7).
701
+ backup_dir: Directory to prune. Defaults to
702
+ ``~/.skcapstone/backups/``.
703
+
704
+ Returns:
705
+ list[str]: Paths of deleted backup files.
706
+ """
707
+ if isinstance(self.primary, SQLiteBackend):
708
+ return self.primary.prune_backups(keep=keep, backup_dir=backup_dir)
709
+ return []
420
710
 
421
711
  def reindex(self) -> int:
422
712
  """Rebuild the SQLite index from JSON files.
@@ -442,4 +732,47 @@ class MemoryStore:
442
732
  status["vector"] = self.vector.health_check()
443
733
  except Exception as e:
444
734
  status["vector"] = {"ok": False, "error": str(e)}
735
+ if self.graph:
736
+ try:
737
+ status["graph"] = self.graph.health_check()
738
+ except Exception as e:
739
+ status["graph"] = {"ok": False, "error": str(e)}
445
740
  return status
741
+
742
+
743
+ def _estimate_tokens(text: str) -> int:
744
+ """Estimate token count using word_count * 1.3 approximation.
745
+
746
+ Args:
747
+ text: The text to estimate.
748
+
749
+ Returns:
750
+ int: Approximate token count.
751
+ """
752
+ if not text:
753
+ return 0
754
+ word_count = len(text.split())
755
+ return int(word_count * 1.3)
756
+
757
+
758
+ def _first_n_sentences(text: str, n: int = 2) -> str:
759
+ """Extract the first N sentences from text.
760
+
761
+ Args:
762
+ text: Source text.
763
+ n: Number of sentences to extract.
764
+
765
+ Returns:
766
+ str: The first N sentences, or the full text if fewer exist.
767
+ """
768
+ if not text:
769
+ return ""
770
+ # Split on sentence-ending punctuation followed by whitespace
771
+ import re
772
+
773
+ sentences = re.split(r"(?<=[.!?])\s+", text.strip())
774
+ result = " ".join(sentences[:n])
775
+ # Cap at 200 chars as a safety net
776
+ if len(result) > 200:
777
+ result = result[:197] + "..."
778
+ return result