@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
@@ -0,0 +1,908 @@
1
+ """
2
+ SKMemory Setup Wizard — Docker orchestration for SKVector & SKGraph.
3
+
4
+ Detects Docker, deploys containers via ``docker compose``, verifies
5
+ health, installs Python dependencies, and persists configuration so
6
+ all future ``skmemory`` commands auto-connect.
7
+
8
+ Cross-platform: macOS, Linux, Windows.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ from .config import CONFIG_DIR, SKMemoryConfig, save_config
28
+
29
+ # ─────────────────────────────────────────────────────────
30
+ # Docker Desktop direct download URLs
31
+ # ─────────────────────────────────────────────────────────
32
+
33
+ DOCKER_DESKTOP_DOWNLOAD = {
34
+ "Windows": ("https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe"),
35
+ "Darwin": "https://docs.docker.com/desktop/install/mac-install/",
36
+ }
37
+
38
+ # ─────────────────────────────────────────────────────────
39
+ # Platform detection
40
+ # ─────────────────────────────────────────────────────────
41
+
42
+ DOCKER_COMPOSE_YAML = """\
43
+ # Auto-generated by skmemory setup wizard
44
+ # Source: skmemory/docker-compose.yml
45
+
46
+ services:
47
+ skvector:
48
+ image: qdrant/qdrant:latest
49
+ container_name: skmemory-skvector
50
+ ports:
51
+ - "6333:6333"
52
+ - "6334:6334"
53
+ volumes:
54
+ - skvector_data:/qdrant/storage
55
+ environment:
56
+ - QDRANT__SERVICE__GRPC_PORT=6334
57
+ restart: unless-stopped
58
+ deploy:
59
+ resources:
60
+ limits:
61
+ memory: 512M
62
+
63
+ skgraph:
64
+ image: falkordb/falkordb:latest
65
+ container_name: skmemory-skgraph
66
+ ports:
67
+ - "6379:6379"
68
+ volumes:
69
+ - skgraph_data:/data
70
+ restart: unless-stopped
71
+ deploy:
72
+ resources:
73
+ limits:
74
+ memory: 256M
75
+
76
+ volumes:
77
+ skvector_data:
78
+ driver: local
79
+ skgraph_data:
80
+ driver: local
81
+ """
82
+
83
+
84
+ @dataclass
85
+ class PlatformInfo:
86
+ """Platform and Docker availability snapshot."""
87
+
88
+ os_name: str = ""
89
+ docker_available: bool = False
90
+ compose_available: bool = False
91
+ compose_legacy: bool = False
92
+ docker_version: str = ""
93
+
94
+
95
+ def detect_platform() -> PlatformInfo:
96
+ """Detect OS and Docker/Compose availability.
97
+
98
+ Returns:
99
+ PlatformInfo with detected values.
100
+ """
101
+ info = PlatformInfo(os_name=platform.system())
102
+
103
+ # Docker binary
104
+ if not shutil.which("docker"):
105
+ return info
106
+
107
+ # Docker daemon running?
108
+ try:
109
+ result = subprocess.run(
110
+ ["docker", "info"],
111
+ capture_output=True,
112
+ timeout=10,
113
+ )
114
+ if result.returncode != 0:
115
+ return info
116
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
117
+ return info
118
+
119
+ info.docker_available = True
120
+
121
+ # Docker version
122
+ try:
123
+ result = subprocess.run(
124
+ ["docker", "version", "--format", "{{.Server.Version}}"],
125
+ capture_output=True,
126
+ text=True,
127
+ timeout=10,
128
+ )
129
+ if result.returncode == 0:
130
+ info.docker_version = result.stdout.strip()
131
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
132
+ pass
133
+
134
+ # Compose v2 (docker compose)
135
+ try:
136
+ result = subprocess.run(
137
+ ["docker", "compose", "version"],
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=10,
141
+ )
142
+ if result.returncode == 0:
143
+ info.compose_available = True
144
+ info.compose_legacy = False
145
+ return info
146
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
147
+ pass
148
+
149
+ # Compose v1 (docker-compose)
150
+ try:
151
+ result = subprocess.run(
152
+ ["docker-compose", "--version"],
153
+ capture_output=True,
154
+ text=True,
155
+ timeout=10,
156
+ )
157
+ if result.returncode == 0:
158
+ info.compose_available = True
159
+ info.compose_legacy = True
160
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
161
+ pass
162
+
163
+ return info
164
+
165
+
166
+ def get_docker_install_instructions(os_name: str) -> str:
167
+ """Return platform-specific Docker install instructions.
168
+
169
+ Args:
170
+ os_name: Result of ``platform.system()`` (Linux, Darwin, Windows).
171
+
172
+ Returns:
173
+ Human-readable install guide.
174
+ """
175
+ _instructions = {
176
+ "Linux": (
177
+ "Install Docker Engine:\n"
178
+ " curl -fsSL https://get.docker.com | sh\n"
179
+ " sudo usermod -aG docker $USER\n"
180
+ " (log out and back in, then retry)"
181
+ ),
182
+ "Darwin": (
183
+ "Install Docker Desktop for macOS:\n"
184
+ " https://docs.docker.com/desktop/install/mac-install/\n"
185
+ " Or: brew install --cask docker"
186
+ ),
187
+ "Windows": (
188
+ "Install Docker Desktop for Windows:\n"
189
+ " https://docs.docker.com/desktop/install/windows-install/\n"
190
+ " WSL2 backend recommended."
191
+ ),
192
+ }
193
+ return _instructions.get(os_name, "Install Docker: https://docs.docker.com/get-docker/")
194
+
195
+
196
+ # ─────────────────────────────────────────────────────────
197
+ # Docker installation helpers
198
+ # ─────────────────────────────────────────────────────────
199
+
200
+
201
+ def _open_url_in_browser(url: str) -> None:
202
+ """Open *url* in the default browser — best-effort, never raises."""
203
+ try:
204
+ os_name = platform.system()
205
+ if os_name == "Windows":
206
+ os.system(f'start "" "{url}"')
207
+ elif os_name == "Darwin":
208
+ subprocess.run(["open", url], timeout=5, check=False)
209
+ else:
210
+ subprocess.run(["xdg-open", url], timeout=5, capture_output=True, check=False)
211
+ except Exception:
212
+ pass
213
+
214
+
215
+ def _try_install_docker_linux(echo: Callable, input_fn: Callable) -> bool:
216
+ """Offer to run the Docker convenience install script on Linux.
217
+
218
+ Returns True only if Docker is detected running after the script.
219
+ """
220
+ echo("")
221
+ echo("Docker is required to run SKVector and SKGraph locally.")
222
+ echo("")
223
+ echo("Fastest install on Linux:")
224
+ echo(" curl -fsSL https://get.docker.com | sh")
225
+ echo(" sudo usermod -aG docker $USER (then log out and back in)")
226
+ echo("")
227
+ ans = input_fn("Run the Docker install script now? [y/N] ").strip().lower()
228
+ if ans not in ("y", "yes"):
229
+ echo("")
230
+ echo("Run it yourself when ready, then come back:")
231
+ echo(" skmemory setup wizard")
232
+ return False
233
+
234
+ echo("Running Docker install script (this may take a minute)...")
235
+ try:
236
+ result = subprocess.run(
237
+ "curl -fsSL https://get.docker.com | sh",
238
+ shell=True,
239
+ timeout=300,
240
+ )
241
+ if result.returncode != 0:
242
+ echo("Docker install script failed. Please install manually.")
243
+ return False
244
+ except (subprocess.TimeoutExpired, OSError) as exc:
245
+ echo(f"Installation error: {exc}")
246
+ return False
247
+
248
+ echo("")
249
+ echo("Docker installed!")
250
+ echo("Add yourself to the docker group, then log out/in and re-run the wizard:")
251
+ echo(" sudo usermod -aG docker $USER")
252
+ echo(" skmemory setup wizard")
253
+ # The current shell session needs a new group — require re-run.
254
+ return False
255
+
256
+
257
+ def _try_install_docker_macos(echo: Callable, input_fn: Callable) -> bool:
258
+ """Offer Homebrew install or open Docker Desktop download on macOS.
259
+
260
+ Returns True only if Docker is detected running after install.
261
+ """
262
+ echo("")
263
+ echo("Docker Desktop is required to run SKVector and SKGraph locally.")
264
+ echo("")
265
+
266
+ has_brew = shutil.which("brew") is not None
267
+ if has_brew:
268
+ echo("Option 1 — Install via Homebrew (recommended):")
269
+ echo(" brew install --cask docker")
270
+ echo("")
271
+ echo("Option 2 — Download Docker Desktop:")
272
+ echo(f" {DOCKER_DESKTOP_DOWNLOAD['Darwin']}")
273
+ echo("")
274
+ ans = input_fn("Install via Homebrew now? [Y/n] ").strip().lower()
275
+ if ans in ("", "y", "yes"):
276
+ echo("Running: brew install --cask docker (may take a few minutes)")
277
+ try:
278
+ subprocess.run(
279
+ ["brew", "install", "--cask", "docker"],
280
+ timeout=600,
281
+ check=False,
282
+ )
283
+ except (subprocess.TimeoutExpired, OSError) as exc:
284
+ echo(f"Homebrew install error: {exc}")
285
+ echo("")
286
+ echo("Docker Desktop installed! Launch it from Applications,")
287
+ echo("wait for the whale icon in the menu bar, then run:")
288
+ echo(" skmemory setup wizard")
289
+ return False
290
+ # User said no to Homebrew — fall through to browser option.
291
+
292
+ url = DOCKER_DESKTOP_DOWNLOAD["Darwin"]
293
+ echo(f"Docker Desktop for macOS: {url}")
294
+ echo("")
295
+ ans = input_fn("Open download page in your browser? [Y/n] ").strip().lower()
296
+ if ans in ("", "y", "yes"):
297
+ _open_url_in_browser(url)
298
+ echo("Download page opened.")
299
+ echo("After installing Docker Desktop, run: skmemory setup wizard")
300
+ return False
301
+
302
+
303
+ def _try_install_docker_windows(echo: Callable, input_fn: Callable) -> bool:
304
+ """Offer winget install or open Docker Desktop download on Windows.
305
+
306
+ Returns True only if Docker is detected running after install.
307
+ """
308
+ echo("")
309
+ echo("Docker Desktop for Windows is required to run SKVector and SKGraph locally.")
310
+ echo("WSL2 backend is recommended (selected automatically during install).")
311
+ echo("")
312
+
313
+ installer_url = DOCKER_DESKTOP_DOWNLOAD["Windows"]
314
+ has_winget = shutil.which("winget") is not None
315
+
316
+ if has_winget:
317
+ echo("Option 1 — Install via Windows Package Manager (winget):")
318
+ echo(" winget install --id Docker.DockerDesktop")
319
+ echo("")
320
+ echo("Option 2 — Download the installer directly:")
321
+ echo(f" {installer_url}")
322
+ echo("")
323
+ ans = input_fn("Install via winget now? [Y/n] ").strip().lower()
324
+ if ans in ("", "y", "yes"):
325
+ echo("Running winget install Docker.DockerDesktop ...")
326
+ echo("(This may take several minutes. Admin elevation may be required.)")
327
+ try:
328
+ result = subprocess.run(
329
+ [
330
+ "winget",
331
+ "install",
332
+ "--id",
333
+ "Docker.DockerDesktop",
334
+ "--source",
335
+ "winget",
336
+ "--accept-package-agreements",
337
+ "--accept-source-agreements",
338
+ ],
339
+ timeout=600,
340
+ check=False,
341
+ )
342
+ if result.returncode == 0:
343
+ echo("")
344
+ echo("Docker Desktop installed!")
345
+ echo("Please restart your computer, then start Docker Desktop")
346
+ echo("(look for the whale icon in the system tray), then run:")
347
+ echo(" skmemory setup wizard")
348
+ return False
349
+ else:
350
+ echo("winget install failed. Opening the download page...")
351
+ _open_url_in_browser(installer_url)
352
+ echo("Download the installer, run it, restart, then:")
353
+ echo(" skmemory setup wizard")
354
+ return False
355
+ except (subprocess.TimeoutExpired, OSError) as exc:
356
+ echo(f"winget error: {exc}")
357
+ echo(f"Download manually: {installer_url}")
358
+ return False
359
+
360
+ # No winget — offer browser download.
361
+ echo("Download Docker Desktop for Windows:")
362
+ echo(f" {installer_url}")
363
+ echo("")
364
+ echo("After installing:")
365
+ echo(" 1. Restart your computer")
366
+ echo(" 2. Start Docker Desktop (whale icon in system tray)")
367
+ echo(" 3. Run: skmemory setup wizard")
368
+ echo("")
369
+ ans = input_fn("Open the download page in your browser? [Y/n] ").strip().lower()
370
+ if ans in ("", "y", "yes"):
371
+ _open_url_in_browser(installer_url)
372
+ echo("Download page opened in browser.")
373
+ return False
374
+
375
+
376
+ def try_install_docker(
377
+ os_name: str,
378
+ echo: Callable,
379
+ input_fn: Callable | None = None,
380
+ ) -> bool:
381
+ """Offer to help the user install Docker for their OS.
382
+
383
+ Args:
384
+ os_name: Result of ``platform.system()`` (Linux, Darwin, Windows).
385
+ echo: Output callable.
386
+ input_fn: Input callable (defaults to built-in ``input``).
387
+
388
+ Returns:
389
+ True if Docker is now available (rare — most paths require a re-run
390
+ after install/reboot), False otherwise.
391
+ """
392
+ if input_fn is None:
393
+ input_fn = input
394
+
395
+ if os_name == "Linux":
396
+ return _try_install_docker_linux(echo, input_fn)
397
+ if os_name == "Darwin":
398
+ return _try_install_docker_macos(echo, input_fn)
399
+ if os_name == "Windows":
400
+ return _try_install_docker_windows(echo, input_fn)
401
+
402
+ echo("")
403
+ echo("Docker not found. Install it from: https://docs.docker.com/get-docker/")
404
+ echo("Then run: skmemory setup wizard")
405
+ return False
406
+
407
+
408
+ def check_port_available(port: int) -> bool:
409
+ """Test if a TCP port is available for binding.
410
+
411
+ Args:
412
+ port: Port number to check.
413
+
414
+ Returns:
415
+ True if the port is free, False if occupied.
416
+ """
417
+ try:
418
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
419
+ s.bind(("127.0.0.1", port))
420
+ return True
421
+ except OSError:
422
+ return False
423
+
424
+
425
+ # ─────────────────────────────────────────────────────────
426
+ # Container lifecycle
427
+ # ─────────────────────────────────────────────────────────
428
+
429
+
430
+ def _compose_cmd(use_legacy: bool) -> list[str]:
431
+ """Return the base compose command based on version."""
432
+ if use_legacy:
433
+ return ["docker-compose"]
434
+ return ["docker", "compose"]
435
+
436
+
437
+ def find_compose_file() -> Path:
438
+ """Locate the docker-compose.yml.
439
+
440
+ Checks:
441
+ 1. Package-bundled compose file (next to this source file's parent).
442
+ 2. Falls back to generating one in ``~/.skcapstone/docker-compose.yml``.
443
+
444
+ Returns:
445
+ Path to the compose file.
446
+ """
447
+ # Check package-bundled location
448
+ package_dir = Path(__file__).resolve().parent.parent
449
+ bundled = package_dir / "docker-compose.yml"
450
+ if bundled.exists():
451
+ return bundled
452
+
453
+ # Fall back to ~/.skcapstone/
454
+ fallback = CONFIG_DIR / "docker-compose.yml"
455
+ if not fallback.exists():
456
+ fallback.parent.mkdir(parents=True, exist_ok=True)
457
+ fallback.write_text(DOCKER_COMPOSE_YAML)
458
+ return fallback
459
+
460
+
461
+ def compose_up(
462
+ services: list[str] | None = None,
463
+ compose_file: Path | None = None,
464
+ use_legacy: bool = False,
465
+ ) -> subprocess.CompletedProcess:
466
+ """Start containers via docker compose.
467
+
468
+ Args:
469
+ services: Specific services to start (None = all).
470
+ compose_file: Path to compose file.
471
+ use_legacy: Use ``docker-compose`` instead of ``docker compose``.
472
+
473
+ Returns:
474
+ CompletedProcess from the subprocess call.
475
+ """
476
+ if compose_file is None:
477
+ compose_file = find_compose_file()
478
+
479
+ cmd = _compose_cmd(use_legacy) + ["-f", str(compose_file), "up", "-d"]
480
+ if services:
481
+ cmd.extend(services)
482
+
483
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=120)
484
+
485
+
486
+ def compose_down(
487
+ compose_file: Path | None = None,
488
+ remove_volumes: bool = False,
489
+ use_legacy: bool = False,
490
+ ) -> subprocess.CompletedProcess:
491
+ """Stop and remove containers.
492
+
493
+ Args:
494
+ compose_file: Path to compose file.
495
+ remove_volumes: Also remove data volumes.
496
+ use_legacy: Use ``docker-compose`` instead of ``docker compose``.
497
+
498
+ Returns:
499
+ CompletedProcess from the subprocess call.
500
+ """
501
+ if compose_file is None:
502
+ compose_file = find_compose_file()
503
+
504
+ cmd = _compose_cmd(use_legacy) + ["-f", str(compose_file), "down"]
505
+ if remove_volumes:
506
+ cmd.append("-v")
507
+
508
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=60)
509
+
510
+
511
+ def compose_ps(
512
+ compose_file: Path | None = None,
513
+ use_legacy: bool = False,
514
+ ) -> subprocess.CompletedProcess:
515
+ """Show container status.
516
+
517
+ Args:
518
+ compose_file: Path to compose file.
519
+ use_legacy: Use ``docker-compose`` instead of ``docker compose``.
520
+
521
+ Returns:
522
+ CompletedProcess from the subprocess call.
523
+ """
524
+ if compose_file is None:
525
+ compose_file = find_compose_file()
526
+
527
+ cmd = _compose_cmd(use_legacy) + ["-f", str(compose_file), "ps"]
528
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=30)
529
+
530
+
531
+ # ─────────────────────────────────────────────────────────
532
+ # Health checks
533
+ # ─────────────────────────────────────────────────────────
534
+
535
+
536
+ def check_skvector_health(url: str = "http://localhost:6333", timeout: int = 30) -> bool:
537
+ """Poll SKVector's /healthz endpoint until healthy or timeout.
538
+
539
+ Args:
540
+ url: SKVector (Qdrant) REST API base URL.
541
+ timeout: Max seconds to wait.
542
+
543
+ Returns:
544
+ True if SKVector responded healthy within the timeout.
545
+ """
546
+ deadline = time.monotonic() + timeout
547
+ healthz = f"{url.rstrip('/')}/healthz"
548
+
549
+ while time.monotonic() < deadline:
550
+ try:
551
+ req = urllib.request.Request(healthz, method="GET")
552
+ with urllib.request.urlopen(req, timeout=3) as resp:
553
+ if resp.status == 200:
554
+ return True
555
+ except (urllib.error.URLError, OSError, TimeoutError):
556
+ pass
557
+ time.sleep(2)
558
+
559
+ return False
560
+
561
+
562
+ def check_skgraph_health(host: str = "localhost", port: int = 6379, timeout: int = 30) -> bool:
563
+ """Send Redis PING to SKGraph and wait for PONG.
564
+
565
+ Args:
566
+ host: SKGraph (FalkorDB) hostname.
567
+ port: SKGraph port (Redis protocol).
568
+ timeout: Max seconds to wait.
569
+
570
+ Returns:
571
+ True if PONG received within the timeout.
572
+ """
573
+ deadline = time.monotonic() + timeout
574
+
575
+ while time.monotonic() < deadline:
576
+ try:
577
+ with socket.create_connection((host, port), timeout=3) as sock:
578
+ sock.sendall(b"PING\r\n")
579
+ data = sock.recv(64)
580
+ if b"+PONG" in data:
581
+ return True
582
+ except (OSError, TimeoutError):
583
+ pass
584
+ time.sleep(2)
585
+
586
+ return False
587
+
588
+
589
+ # ─────────────────────────────────────────────────────────
590
+ # Dependency installer
591
+ # ─────────────────────────────────────────────────────────
592
+
593
+
594
+ def install_python_deps(backends: list[str]) -> subprocess.CompletedProcess:
595
+ """Install Python packages for the selected backends.
596
+
597
+ Args:
598
+ backends: List of backend names (``"skvector"``, ``"skgraph"``).
599
+
600
+ Returns:
601
+ CompletedProcess from pip.
602
+ """
603
+ packages: list[str] = []
604
+ if "skvector" in backends:
605
+ packages.extend(["qdrant-client", "sentence-transformers"])
606
+ if "skgraph" in backends:
607
+ packages.append("falkordb")
608
+
609
+ if not packages:
610
+ return subprocess.CompletedProcess(args=[], returncode=0)
611
+
612
+ cmd = [sys.executable, "-m", "pip", "install"] + packages
613
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=300)
614
+
615
+
616
+ # ─────────────────────────────────────────────────────────
617
+ # Main wizard
618
+ # ─────────────────────────────────────────────────────────
619
+
620
+
621
+ def run_setup_wizard(
622
+ enable_skvector: bool = True,
623
+ enable_skgraph: bool = True,
624
+ skip_deps: bool = False,
625
+ non_interactive: bool = False,
626
+ deployment_mode: str | None = None,
627
+ echo: Callable | None = None,
628
+ input_fn: Callable | None = None,
629
+ ) -> dict:
630
+ """Run the full interactive setup wizard.
631
+
632
+ Args:
633
+ enable_skvector: Include SKVector (vector search) backend.
634
+ enable_skgraph: Include SKGraph (graph db) backend.
635
+ skip_deps: Skip Python dependency installation.
636
+ non_interactive: Don't prompt — use defaults (implies local mode).
637
+ deployment_mode: ``"local"`` (Docker), ``"remote"`` (SaaS / custom URL),
638
+ or ``None`` to ask interactively.
639
+ echo: Callable for output (default: print).
640
+ input_fn: Callable for text input (default: built-in ``input``).
641
+
642
+ Returns:
643
+ dict with wizard results including ``success``, ``mode``,
644
+ ``services``, ``health``, and ``config_path``.
645
+ """
646
+ if echo is None:
647
+ echo = print
648
+ # Wrap echo so it accepts (and ignores) kwargs like end=
649
+ _raw_echo = echo
650
+
651
+ def echo(msg="", **kwargs): # noqa: F811
652
+ _raw_echo(msg)
653
+
654
+ if input_fn is None:
655
+ input_fn = input
656
+
657
+ result: dict = {
658
+ "success": False,
659
+ "mode": deployment_mode or "local",
660
+ "services": [],
661
+ "health": {},
662
+ "config_path": None,
663
+ "errors": [],
664
+ }
665
+
666
+ echo("")
667
+ echo("SKMemory Setup Wizard")
668
+ echo("=" * 40)
669
+
670
+ # ── Step 0: local vs remote ──────────────────────────────────────────────
671
+ if not non_interactive and deployment_mode is None:
672
+ echo("")
673
+ echo("SKVector and SKGraph can run:")
674
+ echo(" [1] Locally with Docker (private, no cloud needed)")
675
+ echo(" [2] Remote / SaaS URL (Qdrant Cloud, self-hosted server, etc.)")
676
+ echo("")
677
+ choice = input_fn("Choice [1]: ").strip()
678
+ deployment_mode = "remote" if choice == "2" else "local"
679
+ result["mode"] = deployment_mode
680
+
681
+ if deployment_mode is None:
682
+ deployment_mode = "local"
683
+
684
+ # ── Remote / SaaS path ──────────────────────────────────────────────────
685
+ if deployment_mode == "remote":
686
+ echo("")
687
+ echo("Enter your remote backend URLs.")
688
+ echo("Press Enter to skip a backend you don't want to enable.")
689
+ echo("")
690
+
691
+ skvector_url: str | None = None
692
+ skvector_key: str | None = None
693
+ skgraph_url: str | None = None
694
+
695
+ if enable_skvector:
696
+ raw = input_fn("SKVector URL (e.g. https://xyz.cloud.qdrant.io:6333): ").strip()
697
+ if raw:
698
+ skvector_url = raw
699
+ key_raw = input_fn("SKVector API key (press Enter if none): ").strip()
700
+ skvector_key = key_raw or None
701
+
702
+ if enable_skgraph:
703
+ raw = input_fn("SKGraph URL (e.g. redis://myserver:6379): ").strip()
704
+ if raw:
705
+ skgraph_url = raw
706
+
707
+ if not skvector_url and not skgraph_url:
708
+ echo("No URLs provided. Nothing configured.")
709
+ result["success"] = True
710
+ return result
711
+
712
+ backends_remote: list[str] = []
713
+ if skvector_url:
714
+ backends_remote.append("skvector")
715
+ if skgraph_url:
716
+ backends_remote.append("skgraph")
717
+
718
+ if not skip_deps:
719
+ echo("Installing Python deps...", end=" ")
720
+ pip_result = install_python_deps(backends_remote)
721
+ if pip_result.returncode != 0:
722
+ echo("failed!")
723
+ echo(f" {pip_result.stderr.strip()[:200]}")
724
+ result["errors"].append("pip install failed")
725
+ else:
726
+ echo("done")
727
+
728
+ cfg = SKMemoryConfig(
729
+ skvector_url=skvector_url,
730
+ skvector_key=skvector_key,
731
+ skgraph_url=skgraph_url,
732
+ backends_enabled=backends_remote,
733
+ setup_completed_at=datetime.now(timezone.utc).isoformat(),
734
+ )
735
+ config_path = save_config(cfg)
736
+ result["config_path"] = str(config_path)
737
+ echo(f"\nConfig saved: {config_path}")
738
+
739
+ if not any("failed" in e for e in result["errors"]):
740
+ result["success"] = True
741
+ echo("Setup complete!")
742
+ return result
743
+
744
+ # ── Local / Docker path ──────────────────────────────────────────────────
745
+ echo("Checking Docker...", end=" ")
746
+ plat = detect_platform()
747
+
748
+ if not plat.docker_available:
749
+ echo("not found!")
750
+
751
+ if non_interactive:
752
+ echo("")
753
+ echo(get_docker_install_instructions(plat.os_name))
754
+ result["errors"].append("Docker not available")
755
+ return result
756
+
757
+ # Offer to help install Docker for the user's OS.
758
+ installed = try_install_docker(plat.os_name, echo, input_fn)
759
+ if not installed:
760
+ result["errors"].append("Docker not available")
761
+ return result
762
+
763
+ # Re-detect after a successful in-process install (rare but possible).
764
+ plat = detect_platform()
765
+ if not plat.docker_available:
766
+ result["errors"].append("Docker still not available after install attempt")
767
+ return result
768
+
769
+ version_str = f" (Docker {plat.docker_version})" if plat.docker_version else ""
770
+ echo(f"found{version_str}")
771
+
772
+ if not plat.compose_available:
773
+ echo("Docker Compose... not found!")
774
+ echo("Install Docker Compose: https://docs.docker.com/compose/install/")
775
+ result["errors"].append("Docker Compose not available")
776
+ return result
777
+
778
+ compose_type = "v1 (legacy)" if plat.compose_legacy else "v2"
779
+ echo(f"Docker Compose {compose_type}... found")
780
+
781
+ if not non_interactive:
782
+ echo("")
783
+ echo("Your memory system runs with SQLite (zero infrastructure).")
784
+ echo("About to deploy:")
785
+ if enable_skvector:
786
+ echo(" SKVector — vector / semantic search (~200 MB RAM, port 6333)")
787
+ if enable_skgraph:
788
+ echo(" SKGraph — graph / relationship db (~100 MB RAM, port 6379)")
789
+ echo("")
790
+
791
+ services_to_start: list[str] = []
792
+ if enable_skvector:
793
+ services_to_start.append("skvector")
794
+ if enable_skgraph:
795
+ services_to_start.append("skgraph")
796
+
797
+ if not services_to_start:
798
+ echo("No backends selected. Nothing to deploy.")
799
+ result["success"] = True
800
+ return result
801
+
802
+ # 3. Port checks
803
+ echo("Checking ports...", end=" ")
804
+ port_issues = []
805
+ if enable_skvector:
806
+ if not check_port_available(6333):
807
+ port_issues.append("6333 (SKVector REST) in use")
808
+ if not check_port_available(6334):
809
+ port_issues.append("6334 (SKVector gRPC) in use")
810
+ if enable_skgraph and not check_port_available(6379):
811
+ port_issues.append("6379 (SKGraph) in use")
812
+
813
+ if port_issues:
814
+ echo("conflict!")
815
+ for issue in port_issues:
816
+ echo(f" Port {issue}")
817
+ echo("Stop conflicting services or adjust docker-compose.yml ports.")
818
+ result["errors"].append(f"Port conflicts: {', '.join(port_issues)}")
819
+ return result
820
+
821
+ ports_ok = []
822
+ if enable_skvector:
823
+ ports_ok.extend(["6333", "6334"])
824
+ if enable_skgraph:
825
+ ports_ok.append("6379")
826
+ echo(f"{', '.join(ports_ok)} available")
827
+
828
+ # 4. Deploy containers
829
+ compose_file = find_compose_file()
830
+ echo("Starting containers...", end=" ")
831
+ up_result = compose_up(
832
+ services=services_to_start,
833
+ compose_file=compose_file,
834
+ use_legacy=plat.compose_legacy,
835
+ )
836
+ if up_result.returncode != 0:
837
+ echo("failed!")
838
+ echo(f" {up_result.stderr.strip()}")
839
+ result["errors"].append(f"docker compose up failed: {up_result.stderr.strip()}")
840
+ return result
841
+ echo("done")
842
+ result["services"] = services_to_start
843
+
844
+ # 5. Health checks
845
+ echo("Health checks...", end=" ")
846
+ health_parts = []
847
+ if enable_skvector:
848
+ start = time.monotonic()
849
+ skvector_ok = check_skvector_health(timeout=30)
850
+ elapsed = time.monotonic() - start
851
+ result["health"]["skvector"] = skvector_ok
852
+ if skvector_ok:
853
+ health_parts.append(f"SKVector healthy ({elapsed:.1f}s)")
854
+ else:
855
+ health_parts.append("SKVector TIMEOUT")
856
+ result["errors"].append("SKVector health check timed out")
857
+
858
+ if enable_skgraph:
859
+ start = time.monotonic()
860
+ skgraph_ok = check_skgraph_health(timeout=30)
861
+ elapsed = time.monotonic() - start
862
+ result["health"]["skgraph"] = skgraph_ok
863
+ if skgraph_ok:
864
+ health_parts.append(f"SKGraph healthy ({elapsed:.1f}s)")
865
+ else:
866
+ health_parts.append("SKGraph TIMEOUT")
867
+ result["errors"].append("SKGraph health check timed out")
868
+
869
+ echo(", ".join(health_parts))
870
+
871
+ # 6. Python deps
872
+ if not skip_deps:
873
+ echo("Installing Python deps...", end=" ")
874
+ pip_result = install_python_deps(services_to_start)
875
+ if pip_result.returncode != 0:
876
+ echo("failed!")
877
+ echo(f" {pip_result.stderr.strip()[:200]}")
878
+ result["errors"].append("pip install failed")
879
+ else:
880
+ echo("done")
881
+
882
+ # 7. Save config
883
+ backends_enabled = []
884
+ if enable_skvector:
885
+ backends_enabled.append("skvector")
886
+ if enable_skgraph:
887
+ backends_enabled.append("skgraph")
888
+
889
+ cfg = SKMemoryConfig(
890
+ skvector_url="http://localhost:6333" if enable_skvector else None,
891
+ skgraph_url="redis://localhost:6379" if enable_skgraph else None,
892
+ backends_enabled=backends_enabled,
893
+ docker_compose_file=str(compose_file),
894
+ setup_completed_at=datetime.now(timezone.utc).isoformat(),
895
+ )
896
+ config_path = save_config(cfg)
897
+ result["config_path"] = str(config_path)
898
+
899
+ echo(f"\nConfig saved: {config_path}")
900
+
901
+ has_errors = any("timed out" in e or "failed" in e for e in result["errors"])
902
+ if not has_errors:
903
+ result["success"] = True
904
+ echo("Setup complete!")
905
+ else:
906
+ echo("Setup completed with warnings. Check errors above.")
907
+
908
+ return result