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