@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
@@ -20,31 +20,38 @@ left off -- not just the facts, but the feelings.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import logging
23
24
  from datetime import datetime, timezone
24
- from typing import Optional
25
25
 
26
26
  from pydantic import BaseModel, Field
27
27
 
28
+ from .audience import AudienceResolver
29
+
30
+ logger = logging.getLogger("skmemory.ritual")
31
+ from .febs import feb_to_context, load_strongest_feb
28
32
  from .journal import Journal
29
- from .models import MemoryLayer
30
33
  from .seeds import DEFAULT_SEED_DIR, get_germination_prompts, import_seeds
31
- from .soul import SoulBlueprint, load_soul, DEFAULT_SOUL_PATH
34
+ from .soul import DEFAULT_SOUL_PATH, SoulBlueprint, load_soul
32
35
  from .store import MemoryStore
33
36
 
34
37
 
35
38
  class RitualResult(BaseModel):
36
39
  """The output of a rehydration ritual."""
37
40
 
38
- timestamp: str = Field(
39
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
40
- )
41
+ timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
41
42
  soul_loaded: bool = Field(default=False)
42
43
  soul_name: str = Field(default="")
44
+ feb_loaded: bool = Field(default=False)
45
+ feb_emotion: str = Field(default="")
43
46
  seeds_imported: int = Field(default=0)
44
47
  seeds_total: int = Field(default=0)
45
48
  journal_entries: int = Field(default=0)
46
49
  germination_prompts: int = Field(default=0)
47
50
  strongest_memories: int = Field(default=0)
51
+ audience_filtered: bool = Field(
52
+ default=False,
53
+ description="True if content was filtered by audience (channel_id was provided)",
54
+ )
48
55
  context_prompt: str = Field(
49
56
  default="",
50
57
  description="The combined rehydration prompt to inject into context",
@@ -61,6 +68,8 @@ class RitualResult(BaseModel):
61
68
  f" Timestamp: {self.timestamp}",
62
69
  f" Soul loaded: {'Yes' if self.soul_loaded else 'No'}"
63
70
  + (f" ({self.soul_name})" if self.soul_name else ""),
71
+ f" FEB loaded: {'Yes' if self.feb_loaded else 'No'}"
72
+ + (f" ({self.feb_emotion})" if self.feb_emotion else ""),
64
73
  f" Seeds imported: {self.seeds_imported} new / {self.seeds_total} total",
65
74
  f" Journal entries: {self.journal_entries}",
66
75
  f" Germination prompts: {self.germination_prompts}",
@@ -109,7 +118,8 @@ def _first_n_sentences(text: str, n: int = 2) -> str:
109
118
  if not text:
110
119
  return ""
111
120
  import re
112
- sentences = re.split(r'(?<=[.!?])\s+', text.strip())
121
+
122
+ sentences = re.split(r"(?<=[.!?])\s+", text.strip())
113
123
  result = " ".join(sentences[:n])
114
124
  if len(result) > 200:
115
125
  result = result[:197] + "..."
@@ -117,13 +127,16 @@ def _first_n_sentences(text: str, n: int = 2) -> str:
117
127
 
118
128
 
119
129
  def perform_ritual(
120
- store: Optional[MemoryStore] = None,
130
+ store: MemoryStore | None = None,
121
131
  soul_path: str = DEFAULT_SOUL_PATH,
122
132
  seed_dir: str = DEFAULT_SEED_DIR,
123
- journal_path: Optional[str] = None,
133
+ journal_path: str | None = None,
134
+ feb_dir: str | None = None,
124
135
  recent_journal_count: int = 3,
125
136
  strongest_memory_count: int = 5,
126
137
  max_tokens: int = 2000,
138
+ channel_id: str | None = None,
139
+ audience_resolver: AudienceResolver | None = None,
127
140
  ) -> RitualResult:
128
141
  """Perform the memory rehydration ritual (token-optimized).
129
142
 
@@ -136,6 +149,15 @@ def perform_ritual(
136
149
 
137
150
  Target: <2K tokens total for ritual context.
138
151
 
152
+ When ``channel_id`` is provided, memories and seeds are filtered through
153
+ the KYA audience resolver before being included in the context. Content
154
+ whose ``context_tag`` trust level exceeds the audience's minimum trust
155
+ level is silently dropped. Identity (soul + FEB) is always included
156
+ unfiltered — Lumina is always Lumina.
157
+
158
+ If ``channel_id`` is None (direct DM / unknown), all content is returned
159
+ (Chef context — no filtering applied).
160
+
139
161
  Args:
140
162
  store: The MemoryStore (creates default if None).
141
163
  soul_path: Path to the soul blueprint YAML.
@@ -144,6 +166,10 @@ def perform_ritual(
144
166
  recent_journal_count: How many recent journal entries to include.
145
167
  strongest_memory_count: How many top-intensity memories to include.
146
168
  max_tokens: Token budget for the ritual context (default: 2000).
169
+ channel_id: Optional channel identifier for KYA audience filtering.
170
+ If None, no filtering is applied (Chef context).
171
+ audience_resolver: Optional pre-built AudienceResolver instance.
172
+ Created from default config if not provided.
147
173
 
148
174
  Returns:
149
175
  RitualResult: Everything the ritual produced.
@@ -155,6 +181,18 @@ def perform_ritual(
155
181
  prompt_sections: list[str] = []
156
182
  used_tokens = 0
157
183
 
184
+ # --- KYA: Resolve audience for filtering ---
185
+ _audience = None
186
+ if channel_id is not None:
187
+ resolver = audience_resolver or AudienceResolver()
188
+ _audience = resolver.resolve_audience(channel_id)
189
+ result.audience_filtered = True
190
+ logger.info(
191
+ "KYA: channel=%s audience=%s min_trust=%s exclusions=%s",
192
+ channel_id, _audience.name, _audience.min_trust.name,
193
+ _audience.exclusions,
194
+ )
195
+
158
196
  # --- Step 1: Load soul blueprint (compact) ---
159
197
  soul = load_soul(soul_path)
160
198
  if soul is not None:
@@ -166,12 +204,35 @@ def perform_ritual(
166
204
  used_tokens += _estimate_tokens(section)
167
205
  prompt_sections.append(section)
168
206
 
207
+ # --- Step 1.5: Load FEB emotional state ---
208
+ feb = load_strongest_feb(feb_dir=feb_dir)
209
+ if feb is not None:
210
+ result.feb_loaded = True
211
+ result.feb_emotion = feb.get("emotional_payload", {}).get("primary_emotion", "")
212
+ feb_context = feb_to_context(feb)
213
+ if feb_context.strip():
214
+ section = "=== EMOTIONAL STATE (FEB) ===\n" + feb_context
215
+ section_tokens = _estimate_tokens(section)
216
+ if used_tokens + section_tokens <= max_tokens:
217
+ used_tokens += section_tokens
218
+ prompt_sections.append(section)
219
+
169
220
  # --- Step 2: Import new seeds (titles only) ---
170
221
  newly_imported = import_seeds(store, seed_dir=seed_dir)
171
222
  result.seeds_imported = len(newly_imported)
172
223
  all_seeds = store.list_memories(tags=["seed"])
173
224
  result.seeds_total = len(all_seeds)
174
225
 
226
+ # KYA: filter seeds by audience
227
+ if _audience is not None:
228
+ resolver = audience_resolver or AudienceResolver()
229
+ all_seeds = [
230
+ s for s in all_seeds
231
+ if resolver.is_memory_allowed(s.context_tag, _audience, s.tags)
232
+ ]
233
+ logger.info("KYA: %d seeds after audience filter", len(all_seeds))
234
+ result.seeds_total = len(all_seeds)
235
+
175
236
  if all_seeds:
176
237
  seed_titles = [s.title for s in all_seeds[:10]]
177
238
  section = "=== SEEDS ===\n" + ", ".join(seed_titles)
@@ -213,15 +274,35 @@ def perform_ritual(
213
274
  used_tokens += section_tokens
214
275
  prompt_sections.append(section)
215
276
 
216
- # --- Step 5: Recall strongest emotional memories (compact) ---
277
+ # --- Step 5: Recall strongest emotional memories (compact + KYA filtered) ---
217
278
  from .backends.sqlite_backend import SQLiteBackend
218
279
 
219
280
  if isinstance(store.primary, SQLiteBackend):
281
+ # Fetch extra to allow for KYA filtering
282
+ fetch_limit = strongest_memory_count * 3 if _audience else strongest_memory_count
220
283
  summaries = store.primary.list_summaries(
221
- limit=strongest_memory_count,
222
- order_by="emotional_intensity",
284
+ limit=fetch_limit,
285
+ order_by="recency_weighted_intensity",
223
286
  min_intensity=1.0,
224
287
  )
288
+
289
+ # KYA: filter summaries by audience
290
+ if _audience is not None:
291
+ resolver = audience_resolver or AudienceResolver()
292
+ filtered = []
293
+ for s in summaries:
294
+ ctx = s.get("context_tag", "@chef-only") or "@chef-only"
295
+ tags = s.get("tags", []) or []
296
+ if resolver.is_memory_allowed(ctx, _audience, tags):
297
+ filtered.append(s)
298
+ if len(filtered) >= strongest_memory_count:
299
+ break
300
+ logger.info(
301
+ "KYA: %d/%d strongest memories passed audience filter",
302
+ len(filtered), len(summaries),
303
+ )
304
+ summaries = filtered
305
+
225
306
  result.strongest_memories = len(summaries)
226
307
 
227
308
  if summaries:
@@ -240,6 +321,16 @@ def perform_ritual(
240
321
  prompt_sections.append("\n".join(mem_lines))
241
322
  else:
242
323
  all_memories = store.list_memories(limit=200)
324
+
325
+ # KYA: filter memories by audience
326
+ if _audience is not None:
327
+ resolver = audience_resolver or AudienceResolver()
328
+ all_memories = [
329
+ m for m in all_memories
330
+ if resolver.is_memory_allowed(m.context_tag, _audience, m.tags)
331
+ ]
332
+ logger.info("KYA: %d memories after audience filter", len(all_memories))
333
+
243
334
  by_intensity = sorted(
244
335
  all_memories,
245
336
  key=lambda m: m.emotional.intensity,
@@ -276,7 +367,7 @@ def perform_ritual(
276
367
  return result
277
368
 
278
369
 
279
- def quick_rehydrate(store: Optional[MemoryStore] = None) -> str:
370
+ def quick_rehydrate(store: MemoryStore | None = None) -> str:
280
371
  """Convenience function: perform ritual and return just the prompt.
281
372
 
282
373
  Args:
package/skmemory/seeds.py CHANGED
@@ -14,9 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import logging
17
- import os
18
17
  from pathlib import Path
19
- from typing import Optional
20
18
 
21
19
  from .agents import get_agent_paths
22
20
  from .models import EmotionalSnapshot, Memory, SeedMemory
@@ -45,7 +43,7 @@ def scan_seed_directory(seed_dir: str = DEFAULT_SEED_DIR) -> list[Path]:
45
43
  return sorted(seed_path.glob("*.seed.json"))
46
44
 
47
45
 
48
- def _parse_cloud9_format(raw: dict, path: Path) -> Optional[SeedMemory]:
46
+ def _parse_cloud9_format(raw: dict, path: Path) -> SeedMemory | None:
49
47
  """Parse alternative Cloud 9 seed format with 'seed_metadata' top-level key.
50
48
 
51
49
  This format uses:
@@ -124,7 +122,7 @@ def _parse_cloud9_format(raw: dict, path: Path) -> Optional[SeedMemory]:
124
122
  )
125
123
 
126
124
 
127
- def parse_seed_file(path: Path) -> Optional[SeedMemory]:
125
+ def parse_seed_file(path: Path) -> SeedMemory | None:
128
126
  """Parse a Cloud 9 seed JSON file into a SeedMemory.
129
127
 
130
128
  Handles the Cloud 9 seed format:
@@ -224,8 +222,7 @@ def validate_seed_data(data: dict) -> dict:
224
222
 
225
223
  # -- Required: version --
226
224
  if is_cloud9:
227
- version = (data.get("seed_metadata", {}).get("version")
228
- or data.get("version"))
225
+ version = data.get("seed_metadata", {}).get("version") or data.get("version")
229
226
  else:
230
227
  version = data.get("version")
231
228
  if not version:
@@ -246,14 +243,13 @@ def validate_seed_data(data: dict) -> dict:
246
243
  # -- Timestamp validation helper --
247
244
  def _check_ts(value: str, field: str) -> None:
248
245
  from datetime import datetime as _dt
246
+
249
247
  if not isinstance(value, str) or not value.strip():
250
248
  return
251
249
  try:
252
250
  _dt.fromisoformat(value.replace("Z", "+00:00"))
253
251
  except (ValueError, TypeError):
254
- result["errors"].append(
255
- f"{field} is not a valid ISO 8601 timestamp: {value!r}"
256
- )
252
+ result["errors"].append(f"{field} is not a valid ISO 8601 timestamp: {value!r}")
257
253
  result["valid"] = False
258
254
 
259
255
  if is_cloud9:
@@ -278,9 +274,7 @@ def validate_seed_data(data: dict) -> dict:
278
274
  return
279
275
  for i, tag in enumerate(tags):
280
276
  if not isinstance(tag, str):
281
- result["errors"].append(
282
- f"{field}[{i}] must be a string, got {type(tag).__name__}"
283
- )
277
+ result["errors"].append(f"{field}[{i}] must be a string, got {type(tag).__name__}")
284
278
  result["valid"] = False
285
279
 
286
280
  md = data.get("metadata", {})
@@ -289,25 +283,26 @@ def validate_seed_data(data: dict) -> dict:
289
283
 
290
284
  # -- Emotional signature ranges --
291
285
  if is_cloud9:
292
- emo = (data.get("experience_summary", {})
293
- .get("emotional_snapshot",
294
- data.get("experience_summary", {})
295
- .get("emotional_signature", {})))
286
+ emo = data.get("experience_summary", {}).get(
287
+ "emotional_snapshot", data.get("experience_summary", {}).get("emotional_signature", {})
288
+ )
296
289
  else:
297
290
  emo = data.get("experience", {}).get("emotional_signature", {})
298
291
  if isinstance(emo, dict):
299
292
  intensity = emo.get("intensity")
300
- if intensity is not None and isinstance(intensity, (int, float)):
301
- if not (0.0 <= float(intensity) <= 10.0):
302
- result["warnings"].append(
303
- f"emotional intensity={intensity} outside 0-10 range"
304
- )
293
+ if (
294
+ intensity is not None
295
+ and isinstance(intensity, (int, float))
296
+ and not (0.0 <= float(intensity) <= 10.0)
297
+ ):
298
+ result["warnings"].append(f"emotional intensity={intensity} outside 0-10 range")
305
299
  valence = emo.get("valence")
306
- if valence is not None and isinstance(valence, (int, float)):
307
- if not (-1.0 <= float(valence) <= 1.0):
308
- result["warnings"].append(
309
- f"emotional valence={valence} outside -1 to 1 range"
310
- )
300
+ if (
301
+ valence is not None
302
+ and isinstance(valence, (int, float))
303
+ and not (-1.0 <= float(valence) <= 1.0)
304
+ ):
305
+ result["warnings"].append(f"emotional valence={valence} outside -1 to 1 range")
311
306
  labels = emo.get("labels", emo.get("emotions"))
312
307
  if labels is not None:
313
308
  _check_tags(labels, "emotional.labels")
@@ -19,21 +19,19 @@ import sys
19
19
  import time
20
20
  import urllib.error
21
21
  import urllib.request
22
- from dataclasses import dataclass, field
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
23
24
  from datetime import datetime, timezone
24
25
  from pathlib import Path
25
- from typing import Callable, Optional
26
26
 
27
- from .config import CONFIG_DIR, SKMemoryConfig, load_config, save_config
27
+ from .config import CONFIG_DIR, SKMemoryConfig, save_config
28
28
 
29
29
  # ─────────────────────────────────────────────────────────
30
30
  # Docker Desktop direct download URLs
31
31
  # ─────────────────────────────────────────────────────────
32
32
 
33
33
  DOCKER_DESKTOP_DOWNLOAD = {
34
- "Windows": (
35
- "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe"
36
- ),
34
+ "Windows": ("https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe"),
37
35
  "Darwin": "https://docs.docker.com/desktop/install/mac-install/",
38
36
  }
39
37
 
@@ -174,26 +172,25 @@ def get_docker_install_instructions(os_name: str) -> str:
174
172
  Returns:
175
173
  Human-readable install guide.
176
174
  """
177
- if os_name == "Linux":
178
- return (
175
+ _instructions = {
176
+ "Linux": (
179
177
  "Install Docker Engine:\n"
180
178
  " curl -fsSL https://get.docker.com | sh\n"
181
179
  " sudo usermod -aG docker $USER\n"
182
180
  " (log out and back in, then retry)"
183
- )
184
- elif os_name == "Darwin":
185
- return (
181
+ ),
182
+ "Darwin": (
186
183
  "Install Docker Desktop for macOS:\n"
187
184
  " https://docs.docker.com/desktop/install/mac-install/\n"
188
185
  " Or: brew install --cask docker"
189
- )
190
- elif os_name == "Windows":
191
- return (
186
+ ),
187
+ "Windows": (
192
188
  "Install Docker Desktop for Windows:\n"
193
189
  " https://docs.docker.com/desktop/install/windows-install/\n"
194
190
  " WSL2 backend recommended."
195
- )
196
- return "Install Docker: https://docs.docker.com/get-docker/"
191
+ ),
192
+ }
193
+ return _instructions.get(os_name, "Install Docker: https://docs.docker.com/get-docker/")
197
194
 
198
195
 
199
196
  # ─────────────────────────────────────────────────────────
@@ -210,8 +207,7 @@ def _open_url_in_browser(url: str) -> None:
210
207
  elif os_name == "Darwin":
211
208
  subprocess.run(["open", url], timeout=5, check=False)
212
209
  else:
213
- subprocess.run(["xdg-open", url], timeout=5,
214
- capture_output=True, check=False)
210
+ subprocess.run(["xdg-open", url], timeout=5, capture_output=True, check=False)
215
211
  except Exception:
216
212
  pass
217
213
 
@@ -331,9 +327,12 @@ def _try_install_docker_windows(echo: Callable, input_fn: Callable) -> bool:
331
327
  try:
332
328
  result = subprocess.run(
333
329
  [
334
- "winget", "install",
335
- "--id", "Docker.DockerDesktop",
336
- "--source", "winget",
330
+ "winget",
331
+ "install",
332
+ "--id",
333
+ "Docker.DockerDesktop",
334
+ "--source",
335
+ "winget",
337
336
  "--accept-package-agreements",
338
337
  "--accept-source-agreements",
339
338
  ],
@@ -350,7 +349,7 @@ def _try_install_docker_windows(echo: Callable, input_fn: Callable) -> bool:
350
349
  else:
351
350
  echo("winget install failed. Opening the download page...")
352
351
  _open_url_in_browser(installer_url)
353
- echo(f"Download the installer, run it, restart, then:")
352
+ echo("Download the installer, run it, restart, then:")
354
353
  echo(" skmemory setup wizard")
355
354
  return False
356
355
  except (subprocess.TimeoutExpired, OSError) as exc:
@@ -359,7 +358,7 @@ def _try_install_docker_windows(echo: Callable, input_fn: Callable) -> bool:
359
358
  return False
360
359
 
361
360
  # No winget — offer browser download.
362
- echo(f"Download Docker Desktop for Windows:")
361
+ echo("Download Docker Desktop for Windows:")
363
362
  echo(f" {installer_url}")
364
363
  echo("")
365
364
  echo("After installing:")
@@ -377,7 +376,7 @@ def _try_install_docker_windows(echo: Callable, input_fn: Callable) -> bool:
377
376
  def try_install_docker(
378
377
  os_name: str,
379
378
  echo: Callable,
380
- input_fn: Optional[Callable] = None,
379
+ input_fn: Callable | None = None,
381
380
  ) -> bool:
382
381
  """Offer to help the user install Docker for their OS.
383
382
 
@@ -460,8 +459,8 @@ def find_compose_file() -> Path:
460
459
 
461
460
 
462
461
  def compose_up(
463
- services: Optional[list[str]] = None,
464
- compose_file: Optional[Path] = None,
462
+ services: list[str] | None = None,
463
+ compose_file: Path | None = None,
465
464
  use_legacy: bool = False,
466
465
  ) -> subprocess.CompletedProcess:
467
466
  """Start containers via docker compose.
@@ -485,7 +484,7 @@ def compose_up(
485
484
 
486
485
 
487
486
  def compose_down(
488
- compose_file: Optional[Path] = None,
487
+ compose_file: Path | None = None,
489
488
  remove_volumes: bool = False,
490
489
  use_legacy: bool = False,
491
490
  ) -> subprocess.CompletedProcess:
@@ -510,7 +509,7 @@ def compose_down(
510
509
 
511
510
 
512
511
  def compose_ps(
513
- compose_file: Optional[Path] = None,
512
+ compose_file: Path | None = None,
514
513
  use_legacy: bool = False,
515
514
  ) -> subprocess.CompletedProcess:
516
515
  """Show container status.
@@ -560,9 +559,7 @@ def check_skvector_health(url: str = "http://localhost:6333", timeout: int = 30)
560
559
  return False
561
560
 
562
561
 
563
- def check_skgraph_health(
564
- host: str = "localhost", port: int = 6379, timeout: int = 30
565
- ) -> bool:
562
+ def check_skgraph_health(host: str = "localhost", port: int = 6379, timeout: int = 30) -> bool:
566
563
  """Send Redis PING to SKGraph and wait for PONG.
567
564
 
568
565
  Args:
@@ -626,9 +623,9 @@ def run_setup_wizard(
626
623
  enable_skgraph: bool = True,
627
624
  skip_deps: bool = False,
628
625
  non_interactive: bool = False,
629
- deployment_mode: Optional[str] = None,
630
- echo: Optional[Callable] = None,
631
- input_fn: Optional[Callable] = None,
626
+ deployment_mode: str | None = None,
627
+ echo: Callable | None = None,
628
+ input_fn: Callable | None = None,
632
629
  ) -> dict:
633
630
  """Run the full interactive setup wizard.
634
631
 
@@ -691,25 +688,19 @@ def run_setup_wizard(
691
688
  echo("Press Enter to skip a backend you don't want to enable.")
692
689
  echo("")
693
690
 
694
- skvector_url: Optional[str] = None
695
- skvector_key: Optional[str] = None
696
- skgraph_url: Optional[str] = None
691
+ skvector_url: str | None = None
692
+ skvector_key: str | None = None
693
+ skgraph_url: str | None = None
697
694
 
698
695
  if enable_skvector:
699
- raw = input_fn(
700
- "SKVector URL (e.g. https://xyz.cloud.qdrant.io:6333): "
701
- ).strip()
696
+ raw = input_fn("SKVector URL (e.g. https://xyz.cloud.qdrant.io:6333): ").strip()
702
697
  if raw:
703
698
  skvector_url = raw
704
- key_raw = input_fn(
705
- "SKVector API key (press Enter if none): "
706
- ).strip()
699
+ key_raw = input_fn("SKVector API key (press Enter if none): ").strip()
707
700
  skvector_key = key_raw or None
708
701
 
709
702
  if enable_skgraph:
710
- raw = input_fn(
711
- "SKGraph URL (e.g. redis://myserver:6379): "
712
- ).strip()
703
+ raw = input_fn("SKGraph URL (e.g. redis://myserver:6379): ").strip()
713
704
  if raw:
714
705
  skgraph_url = raw
715
706
 
@@ -816,9 +807,8 @@ def run_setup_wizard(
816
807
  port_issues.append("6333 (SKVector REST) in use")
817
808
  if not check_port_available(6334):
818
809
  port_issues.append("6334 (SKVector gRPC) in use")
819
- if enable_skgraph:
820
- if not check_port_available(6379):
821
- port_issues.append("6379 (SKGraph) in use")
810
+ if enable_skgraph and not check_port_available(6379):
811
+ port_issues.append("6379 (SKGraph) in use")
822
812
 
823
813
  if port_issues:
824
814
  echo("conflict!")
@@ -908,9 +898,7 @@ def run_setup_wizard(
908
898
 
909
899
  echo(f"\nConfig saved: {config_path}")
910
900
 
911
- has_errors = any(
912
- "timed out" in e or "failed" in e for e in result["errors"]
913
- )
901
+ has_errors = any("timed out" in e or "failed" in e for e in result["errors"])
914
902
  if not has_errors:
915
903
  result["success"] = True
916
904
  echo("Setup complete!")
@@ -24,7 +24,7 @@ import json
24
24
  import logging
25
25
  from datetime import datetime, timezone
26
26
  from pathlib import Path
27
- from typing import Any, Optional
27
+ from typing import Any
28
28
 
29
29
  from pydantic import BaseModel, Field
30
30
 
@@ -160,13 +160,15 @@ class MemorySharer:
160
160
  checksum=checksum,
161
161
  metadata={
162
162
  "filter_tags": share_filter.tags,
163
- "filter_layers": [l.value for l in share_filter.layers],
163
+ "filter_layers": [lbl.value for lbl in share_filter.layers],
164
164
  },
165
165
  )
166
166
 
167
167
  logger.info(
168
168
  "Exported %d memories for %s (bundle %s)",
169
- len(serialized), recipient or "anyone", bundle.bundle_id,
169
+ len(serialized),
170
+ recipient or "anyone",
171
+ bundle.bundle_id,
170
172
  )
171
173
  return bundle
172
174
 
@@ -198,7 +200,8 @@ class MemorySharer:
198
200
  if bundle.checksum and actual_checksum != bundle.checksum:
199
201
  logger.error(
200
202
  "Bundle checksum mismatch! Expected %s, got %s",
201
- bundle.checksum[:16], actual_checksum[:16],
203
+ bundle.checksum[:16],
204
+ actual_checksum[:16],
202
205
  )
203
206
  return {"imported": 0, "skipped": 0, "errors": bundle.memory_count}
204
207
 
@@ -245,7 +248,10 @@ class MemorySharer:
245
248
 
246
249
  logger.info(
247
250
  "Imported %d/%d memories from %s (bundle %s)",
248
- imported, bundle.memory_count, bundle.sharer, bundle.bundle_id,
251
+ imported,
252
+ bundle.memory_count,
253
+ bundle.sharer,
254
+ bundle.bundle_id,
249
255
  )
250
256
  return {"imported": imported, "skipped": skipped, "errors": errors}
251
257