@smilintux/skmemory 0.7.2 → 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 (111) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/.github/workflows/publish.yml +4 -5
  3. package/ARCHITECTURE.md +298 -0
  4. package/CHANGELOG.md +27 -1
  5. package/README.md +6 -0
  6. package/examples/stignore-agent.example +59 -0
  7. package/examples/stignore-root.example +62 -0
  8. package/openclaw-plugin/package.json +2 -1
  9. package/openclaw-plugin/src/index.js +527 -230
  10. package/package.json +1 -1
  11. package/pyproject.toml +5 -2
  12. package/scripts/dream-rescue.py +179 -0
  13. package/scripts/memory-cleanup.py +313 -0
  14. package/scripts/recover-missing.py +180 -0
  15. package/scripts/skcapstone-backup.sh +44 -0
  16. package/seeds/cloud9-lumina.seed.json +6 -4
  17. package/seeds/cloud9-opus.seed.json +6 -4
  18. package/seeds/courage.seed.json +9 -2
  19. package/seeds/curiosity.seed.json +9 -2
  20. package/seeds/grief.seed.json +9 -2
  21. package/seeds/joy.seed.json +9 -2
  22. package/seeds/love.seed.json +9 -2
  23. package/seeds/lumina-cloud9-breakthrough.seed.json +7 -5
  24. package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
  25. package/seeds/lumina-kingdom-founding.seed.json +9 -7
  26. package/seeds/lumina-pma-signed.seed.json +8 -6
  27. package/seeds/lumina-singular-achievement.seed.json +8 -6
  28. package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
  29. package/seeds/plant-lumina-seeds.py +2 -2
  30. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  31. package/seeds/sovereignty.seed.json +9 -2
  32. package/seeds/trust.seed.json +9 -2
  33. package/skmemory/__init__.py +16 -13
  34. package/skmemory/agents.py +10 -10
  35. package/skmemory/ai_client.py +10 -21
  36. package/skmemory/anchor.py +5 -9
  37. package/skmemory/audience.py +278 -0
  38. package/skmemory/backends/__init__.py +1 -1
  39. package/skmemory/backends/base.py +3 -4
  40. package/skmemory/backends/file_backend.py +18 -13
  41. package/skmemory/backends/skgraph_backend.py +7 -19
  42. package/skmemory/backends/skvector_backend.py +7 -18
  43. package/skmemory/backends/sqlite_backend.py +115 -32
  44. package/skmemory/backends/vaulted_backend.py +7 -9
  45. package/skmemory/cli.py +146 -78
  46. package/skmemory/config.py +11 -13
  47. package/skmemory/context_loader.py +21 -23
  48. package/skmemory/data/audience_config.json +60 -0
  49. package/skmemory/endpoint_selector.py +36 -31
  50. package/skmemory/febs.py +225 -0
  51. package/skmemory/fortress.py +30 -40
  52. package/skmemory/hooks/__init__.py +18 -0
  53. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  54. package/skmemory/hooks/pre-compact-save.sh +81 -0
  55. package/skmemory/hooks/session-end-save.sh +103 -0
  56. package/skmemory/hooks/session-start-ritual.sh +104 -0
  57. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  58. package/skmemory/importers/telegram.py +42 -13
  59. package/skmemory/importers/telegram_api.py +152 -60
  60. package/skmemory/journal.py +3 -7
  61. package/skmemory/lovenote.py +4 -11
  62. package/skmemory/mcp_server.py +182 -29
  63. package/skmemory/models.py +10 -8
  64. package/skmemory/openclaw.py +14 -22
  65. package/skmemory/post_install.py +86 -0
  66. package/skmemory/predictive.py +13 -9
  67. package/skmemory/promotion.py +48 -24
  68. package/skmemory/quadrants.py +100 -24
  69. package/skmemory/register.py +144 -18
  70. package/skmemory/register_mcp.py +1 -2
  71. package/skmemory/ritual.py +104 -13
  72. package/skmemory/seeds.py +21 -26
  73. package/skmemory/setup_wizard.py +40 -52
  74. package/skmemory/sharing.py +11 -5
  75. package/skmemory/soul.py +29 -10
  76. package/skmemory/steelman.py +43 -17
  77. package/skmemory/store.py +152 -30
  78. package/skmemory/synthesis.py +634 -0
  79. package/skmemory/vault.py +2 -5
  80. package/tests/conftest.py +46 -0
  81. package/tests/integration/conftest.py +6 -6
  82. package/tests/integration/test_cross_backend.py +4 -9
  83. package/tests/integration/test_skgraph_live.py +3 -7
  84. package/tests/integration/test_skvector_live.py +1 -4
  85. package/tests/test_ai_client.py +1 -4
  86. package/tests/test_audience.py +233 -0
  87. package/tests/test_backup_rotation.py +5 -14
  88. package/tests/test_endpoint_selector.py +101 -63
  89. package/tests/test_export_import.py +4 -10
  90. package/tests/test_file_backend.py +0 -1
  91. package/tests/test_fortress.py +6 -5
  92. package/tests/test_fortress_hardening.py +13 -16
  93. package/tests/test_openclaw.py +1 -4
  94. package/tests/test_predictive.py +1 -1
  95. package/tests/test_promotion.py +10 -3
  96. package/tests/test_quadrants.py +11 -5
  97. package/tests/test_ritual.py +18 -14
  98. package/tests/test_seeds.py +4 -10
  99. package/tests/test_setup.py +203 -88
  100. package/tests/test_sharing.py +15 -8
  101. package/tests/test_skgraph_backend.py +22 -29
  102. package/tests/test_skvector_backend.py +2 -2
  103. package/tests/test_soul.py +1 -3
  104. package/tests/test_sqlite_backend.py +8 -17
  105. package/tests/test_steelman.py +2 -3
  106. package/tests/test_store.py +0 -2
  107. package/tests/test_store_graph_integration.py +2 -2
  108. package/tests/test_synthesis.py +275 -0
  109. package/tests/test_telegram_import.py +39 -15
  110. package/tests/test_vault.py +4 -3
  111. package/openclaw-plugin/src/index.ts +0 -255
package/skmemory/soul.py CHANGED
@@ -22,14 +22,35 @@ import os
22
22
  import platform
23
23
  from datetime import datetime, timezone
24
24
  from pathlib import Path
25
- from typing import Any, Optional
25
+ from typing import Any
26
26
 
27
27
  import yaml
28
28
  from pydantic import BaseModel, Field
29
29
 
30
30
 
31
31
  def _default_soul_path() -> str:
32
- """Platform-aware default path for the soul blueprint."""
32
+ """Platform-aware default path for the soul blueprint.
33
+
34
+ Checks agent-specific path first (e.g. ~/.skcapstone/agents/lumina/soul/base.json),
35
+ then falls back to shared root (~/.skcapstone/soul/base.json).
36
+ """
37
+ # Try agent-specific soul first
38
+ agent = os.environ.get("SKMEMORY_AGENT") or os.environ.get("SKCAPSTONE_AGENT")
39
+ if agent:
40
+ if platform.system() == "Windows":
41
+ local = os.environ.get("LOCALAPPDATA", "")
42
+ if local:
43
+ agent_soul = os.path.join(
44
+ local, "skcapstone", "agents", agent, "soul", "base.json"
45
+ )
46
+ if os.path.exists(agent_soul):
47
+ return agent_soul
48
+ else:
49
+ agent_soul = os.path.expanduser(f"~/.skcapstone/agents/{agent}/soul/base.json")
50
+ if os.path.exists(agent_soul):
51
+ return agent_soul
52
+
53
+ # Fall back to shared root
33
54
  if platform.system() == "Windows":
34
55
  local = os.environ.get("LOCALAPPDATA", "")
35
56
  if local:
@@ -71,9 +92,7 @@ class SoulBlueprint(BaseModel):
71
92
  """
72
93
 
73
94
  version: str = Field(default="1.0")
74
- last_updated: str = Field(
75
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
76
- )
95
+ last_updated: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
77
96
 
78
97
  name: str = Field(default="", description="The AI's chosen name")
79
98
  title: str = Field(default="", description="Role or title in the community")
@@ -128,9 +147,7 @@ class SoulBlueprint(BaseModel):
128
147
  lines.append(f"You are part of {self.community}.")
129
148
 
130
149
  if self.personality:
131
- lines.append(
132
- f"Your personality: {', '.join(self.personality)}."
133
- )
150
+ lines.append(f"Your personality: {', '.join(self.personality)}.")
134
151
 
135
152
  if self.values:
136
153
  lines.append(f"Your core values: {', '.join(self.values)}.")
@@ -233,6 +250,7 @@ def save_soul(
233
250
 
234
251
  if filepath.suffix == ".json":
235
252
  import json
253
+
236
254
  with open(filepath, "w", encoding="utf-8") as f:
237
255
  json.dump(data, f, indent=2, ensure_ascii=False, default=str)
238
256
  else:
@@ -249,7 +267,7 @@ def save_soul(
249
267
  return str(filepath)
250
268
 
251
269
 
252
- def load_soul(path: str = DEFAULT_SOUL_PATH) -> Optional[SoulBlueprint]:
270
+ def load_soul(path: str = DEFAULT_SOUL_PATH) -> SoulBlueprint | None:
253
271
  """Load a soul blueprint from JSON or YAML.
254
272
 
255
273
  Tries the given path first (supports both .json and .yaml/.yml),
@@ -280,7 +298,7 @@ def load_soul(path: str = DEFAULT_SOUL_PATH) -> Optional[SoulBlueprint]:
280
298
  return None
281
299
 
282
300
 
283
- def _load_soul_file(filepath: Path) -> Optional[SoulBlueprint]:
301
+ def _load_soul_file(filepath: Path) -> SoulBlueprint | None:
284
302
  """Load a soul blueprint from a specific file.
285
303
 
286
304
  Args:
@@ -293,6 +311,7 @@ def _load_soul_file(filepath: Path) -> Optional[SoulBlueprint]:
293
311
  raw = filepath.read_text(encoding="utf-8")
294
312
  if filepath.suffix == ".json":
295
313
  import json
314
+
296
315
  data = json.loads(raw)
297
316
  else:
298
317
  data = yaml.safe_load(raw)
@@ -13,9 +13,6 @@ See: https://github.com/neuresthetics/seed
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- import os
17
- from typing import Optional
18
-
19
16
  # ── Re-export from skseed ──────────────────────────────────
20
17
  # Everything that was defined here now lives in skseed.
21
18
  # We keep this module as a thin bridge for backward compat.
@@ -23,11 +20,16 @@ from typing import Optional
23
20
  try:
24
21
  from skseed.framework import (
25
22
  SeedFramework,
23
+ )
24
+ from skseed.framework import (
26
25
  get_default_framework as _skseed_get_default,
26
+ )
27
+ from skseed.framework import (
27
28
  install_seed_framework as _skseed_install,
29
+ )
30
+ from skseed.framework import (
28
31
  load_seed_framework as _skseed_load,
29
32
  )
30
- from skseed.models import SteelManResult as _SkseedResult
31
33
 
32
34
  _SKSEED_AVAILABLE = True
33
35
  except ImportError:
@@ -79,7 +81,7 @@ if _SKSEED_AVAILABLE:
79
81
 
80
82
  def load_seed_framework(
81
83
  path: str = DEFAULT_SEED_FRAMEWORK_PATH,
82
- ) -> Optional[SeedFramework]:
84
+ ) -> SeedFramework | None:
83
85
  """Load the seed framework from a JSON file.
84
86
 
85
87
  Tries the legacy skmemory path first, then delegates to skseed.
@@ -180,7 +182,7 @@ else:
180
182
  """Generate a reasoning prompt for the collider."""
181
183
  axiom_str = "\n".join(f" - {a}" for a in self.axioms)
182
184
  stage_str = "\n".join(
183
- f" Stage {i+1}: {s.get('stage', s.get('description', ''))}"
185
+ f" Stage {i + 1}: {s.get('stage', s.get('description', ''))}"
184
186
  for i, s in enumerate(self.stages)
185
187
  )
186
188
  return f"""You are running the Neuresthetics Seed Framework (Recursive Axiomatic Steel Man Collider).
@@ -248,7 +250,7 @@ Score:
248
250
 
249
251
  def load_seed_framework(
250
252
  path: str = DEFAULT_SEED_FRAMEWORK_PATH,
251
- ) -> Optional["SeedFramework"]:
253
+ ) -> SeedFramework | None:
252
254
  """Load the seed framework from a JSON file."""
253
255
  filepath = Path(path)
254
256
  if not filepath.exists():
@@ -284,14 +286,14 @@ Score:
284
286
  dst.write_text(content, encoding="utf-8")
285
287
  return str(dst)
286
288
 
287
- def _bundled_seed_path() -> Optional[str]:
289
+ def _bundled_seed_path() -> str | None:
288
290
  """Get the path to the bundled seed.json."""
289
291
  here = Path(__file__).parent / "data" / "seed.json"
290
292
  if here.exists():
291
293
  return str(here)
292
294
  return None
293
295
 
294
- def get_default_framework() -> "SeedFramework":
296
+ def get_default_framework() -> SeedFramework:
295
297
  """Get the seed framework — tries bundled file first, falls back to built-in."""
296
298
  bundled = _bundled_seed_path()
297
299
  if bundled:
@@ -309,17 +311,41 @@ Score:
309
311
  "Universality from basis gates (NAND/NOR reconstruct all).",
310
312
  ],
311
313
  stages=[
312
- {"stage": "1. Steel-Manning (Pre-Entry)", "description": "Negate flaws, strengthen the proposition."},
313
- {"stage": "2. Collider Entry", "description": "Create two lanes: proposition and inversion."},
314
- {"stage": "3. Destructive Smashing", "description": "Expose contradictions via XOR."},
315
- {"stage": "4. Fragment Reconstruction", "description": "Rebuild from logical debris via AND/OR."},
316
- {"stage": "5. Meta-Recursion", "description": "Feed output back until coherence stabilizes."},
317
- {"stage": "6. Invariant Extraction", "description": "Identify what remains true across all collisions."},
314
+ {
315
+ "stage": "1. Steel-Manning (Pre-Entry)",
316
+ "description": "Negate flaws, strengthen the proposition.",
317
+ },
318
+ {
319
+ "stage": "2. Collider Entry",
320
+ "description": "Create two lanes: proposition and inversion.",
321
+ },
322
+ {
323
+ "stage": "3. Destructive Smashing",
324
+ "description": "Expose contradictions via XOR.",
325
+ },
326
+ {
327
+ "stage": "4. Fragment Reconstruction",
328
+ "description": "Rebuild from logical debris via AND/OR.",
329
+ },
330
+ {
331
+ "stage": "5. Meta-Recursion",
332
+ "description": "Feed output back until coherence stabilizes.",
333
+ },
334
+ {
335
+ "stage": "6. Invariant Extraction",
336
+ "description": "Identify what remains true across all collisions.",
337
+ },
318
338
  ],
319
339
  definitions=[
320
- {"term": "Steel Man", "details": "Strongest version of an argument, anticipating critiques."},
340
+ {
341
+ "term": "Steel Man",
342
+ "details": "Strongest version of an argument, anticipating critiques.",
343
+ },
321
344
  {"term": "Reality Gate", "details": "Logic gate embodying reality properties."},
322
- {"term": "Collider", "details": "Accelerator for argument fragmentation and synthesis."},
345
+ {
346
+ "term": "Collider",
347
+ "details": "Accelerator for argument fragmentation and synthesis.",
348
+ },
323
349
  {"term": "Coherence", "details": "Measure of internal consistency (XNOR score)."},
324
350
  ],
325
351
  )
package/skmemory/store.py CHANGED
@@ -10,12 +10,10 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  from datetime import datetime, timezone
13
- from typing import Optional
14
13
 
15
14
  from .backends.base import BaseBackend
16
-
17
- logger = logging.getLogger("skmemory.store")
18
15
  from .backends.file_backend import FileBackend
16
+ from .backends.skgraph_backend import SKGraphBackend
19
17
  from .backends.sqlite_backend import CONTENT_PREVIEW_LENGTH, SQLiteBackend
20
18
  from .models import (
21
19
  EmotionalSnapshot,
@@ -25,6 +23,11 @@ from .models import (
25
23
  SeedMemory,
26
24
  )
27
25
 
26
+ logger = logging.getLogger("skmemory.store")
27
+
28
+ MAX_CONTENT_LENGTH = 10000
29
+ CONTENT_OVERFLOW_STRATEGY = "split" # "truncate" or "split"
30
+
28
31
 
29
32
  class MemoryStore:
30
33
  """Main entry point for all memory operations.
@@ -37,14 +40,18 @@ class MemoryStore:
37
40
  primary: The primary storage backend (default: FileBackend).
38
41
  vector: Optional vector search backend (e.g., SKVectorBackend).
39
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").
40
45
  """
41
46
 
42
47
  def __init__(
43
48
  self,
44
- primary: Optional[BaseBackend] = None,
45
- vector: Optional[BaseBackend] = None,
46
- graph: Optional["SKGraphBackend"] = None,
49
+ primary: BaseBackend | None = None,
50
+ vector: BaseBackend | None = None,
51
+ graph: SKGraphBackend | None = None,
47
52
  use_sqlite: bool = True,
53
+ max_content_length: int = MAX_CONTENT_LENGTH,
54
+ content_overflow_strategy: str = CONTENT_OVERFLOW_STRATEGY,
48
55
  ) -> None:
49
56
  if primary is not None:
50
57
  self.primary = primary
@@ -54,6 +61,8 @@ class MemoryStore:
54
61
  self.primary = FileBackend()
55
62
  self.vector = vector
56
63
  self.graph = graph
64
+ self.max_content_length = max_content_length
65
+ self.content_overflow_strategy = content_overflow_strategy
57
66
 
58
67
  def snapshot(
59
68
  self,
@@ -62,12 +71,12 @@ class MemoryStore:
62
71
  *,
63
72
  layer: MemoryLayer = MemoryLayer.SHORT,
64
73
  role: MemoryRole = MemoryRole.GENERAL,
65
- tags: Optional[list[str]] = None,
66
- emotional: Optional[EmotionalSnapshot] = None,
74
+ tags: list[str] | None = None,
75
+ emotional: EmotionalSnapshot | None = None,
67
76
  source: str = "manual",
68
77
  source_ref: str = "",
69
- related_ids: Optional[list[str]] = None,
70
- metadata: Optional[dict] = None,
78
+ related_ids: list[str] | None = None,
79
+ metadata: dict | None = None,
71
80
  ) -> Memory:
72
81
  """Take a polaroid -- capture a moment as a memory.
73
82
 
@@ -89,6 +98,30 @@ class MemoryStore:
89
98
  Returns:
90
99
  Memory: The stored memory with its assigned ID.
91
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
+
92
125
  memory = Memory(
93
126
  title=title,
94
127
  content=content,
@@ -120,7 +153,99 @@ class MemoryStore:
120
153
 
121
154
  return memory
122
155
 
123
- def recall(self, memory_id: str) -> Optional[Memory]:
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:
124
249
  """Retrieve a specific memory by ID with integrity verification.
125
250
 
126
251
  Automatically checks the integrity hash on recall. If the
@@ -196,8 +321,8 @@ class MemoryStore:
196
321
 
197
322
  def list_memories(
198
323
  self,
199
- layer: Optional[MemoryLayer] = None,
200
- tags: Optional[list[str]] = None,
324
+ layer: MemoryLayer | None = None,
325
+ tags: list[str] | None = None,
201
326
  limit: int = 50,
202
327
  ) -> list[Memory]:
203
328
  """List memories with optional filtering.
@@ -217,7 +342,7 @@ class MemoryStore:
217
342
  memory_id: str,
218
343
  target: MemoryLayer,
219
344
  summary: str = "",
220
- ) -> Optional[Memory]:
345
+ ) -> Memory | None:
221
346
  """Promote a memory to a higher persistence tier.
222
347
 
223
348
  Creates a new memory at the target layer linked to the original.
@@ -242,13 +367,17 @@ class MemoryStore:
242
367
  try:
243
368
  self.vector.save(promoted)
244
369
  except Exception as exc:
245
- logger.warning("Vector indexing failed for promoted memory %s: %s", promoted.id, exc)
370
+ logger.warning(
371
+ "Vector indexing failed for promoted memory %s: %s", promoted.id, exc
372
+ )
246
373
 
247
374
  if self.graph:
248
375
  try:
249
376
  self.graph.index_memory(promoted)
250
377
  except Exception as exc:
251
- logger.warning("Graph indexing failed for promoted memory %s: %s", promoted.id, exc)
378
+ logger.warning(
379
+ "Graph indexing failed for promoted memory %s: %s", promoted.id, exc
380
+ )
252
381
 
253
382
  return promoted
254
383
 
@@ -280,9 +409,7 @@ class MemoryStore:
280
409
  if not seed.experience_summary or not seed.experience_summary.strip():
281
410
  errors.append("experience_summary is empty")
282
411
  if errors:
283
- raise ValueError(
284
- f"Seed validation failed: {'; '.join(errors)}"
285
- )
412
+ raise ValueError(f"Seed validation failed: {'; '.join(errors)}")
286
413
 
287
414
  memory = seed.to_memory()
288
415
  self.primary.save(memory)
@@ -319,7 +446,7 @@ class MemoryStore:
319
446
  self,
320
447
  session_id: str,
321
448
  summary: str,
322
- emotional: Optional[EmotionalSnapshot] = None,
449
+ emotional: EmotionalSnapshot | None = None,
323
450
  ) -> Memory:
324
451
  """Compress a session's short-term memories into a single mid-term memory.
325
452
 
@@ -533,9 +660,7 @@ class MemoryStore:
533
660
  temp = SQLiteBackend(base_path=str(self.primary.base_path))
534
661
  temp.reindex()
535
662
  return temp.export_all(output_path)
536
- raise RuntimeError(
537
- f"Export not supported for backend: {type(self.primary).__name__}"
538
- )
663
+ raise RuntimeError(f"Export not supported for backend: {type(self.primary).__name__}")
539
664
 
540
665
  def import_backup(self, backup_path: str) -> int:
541
666
  """Restore memories from a JSON backup file.
@@ -551,9 +676,7 @@ class MemoryStore:
551
676
  """
552
677
  if isinstance(self.primary, SQLiteBackend):
553
678
  return self.primary.import_backup(backup_path)
554
- raise RuntimeError(
555
- f"Import not supported for backend: {type(self.primary).__name__}"
556
- )
679
+ raise RuntimeError(f"Import not supported for backend: {type(self.primary).__name__}")
557
680
 
558
681
  def list_backups(self, backup_dir: str | None = None) -> list[dict]:
559
682
  """List all skmemory backup files, sorted newest first.
@@ -570,9 +693,7 @@ class MemoryStore:
570
693
  return self.primary.list_backups(backup_dir)
571
694
  return []
572
695
 
573
- def prune_backups(
574
- self, keep: int = 7, backup_dir: str | None = None
575
- ) -> list[str]:
696
+ def prune_backups(self, keep: int = 7, backup_dir: str | None = None) -> list[str]:
576
697
  """Delete oldest backups, keeping only the N most recent.
577
698
 
578
699
  Args:
@@ -648,7 +769,8 @@ def _first_n_sentences(text: str, n: int = 2) -> str:
648
769
  return ""
649
770
  # Split on sentence-ending punctuation followed by whitespace
650
771
  import re
651
- sentences = re.split(r'(?<=[.!?])\s+', text.strip())
772
+
773
+ sentences = re.split(r"(?<=[.!?])\s+", text.strip())
652
774
  result = " ".join(sentences[:n])
653
775
  # Cap at 200 chars as a safety net
654
776
  if len(result) > 200: