@smilintux/skcapstone 0.4.6 → 0.5.0

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 (77) hide show
  1. package/.github/workflows/publish.yml +8 -1
  2. package/docs/CUSTOM_AGENT.md +184 -0
  3. package/docs/GETTING_STARTED.md +3 -0
  4. package/launchd/com.skcapstone.daemon.plist +52 -0
  5. package/launchd/com.skcapstone.memory-compress.plist +45 -0
  6. package/launchd/com.skcapstone.skcomm-heartbeat.plist +33 -0
  7. package/launchd/com.skcapstone.skcomm-queue-drain.plist +34 -0
  8. package/launchd/install-launchd.sh +156 -0
  9. package/package.json +1 -1
  10. package/pyproject.toml +1 -1
  11. package/scripts/archive-sessions.sh +88 -0
  12. package/scripts/install.sh +39 -8
  13. package/scripts/notion-api.py +259 -0
  14. package/scripts/nvidia-proxy.mjs +878 -0
  15. package/scripts/proxy-monitor.sh +89 -0
  16. package/scripts/refresh-anthropic-token.sh +94 -0
  17. package/scripts/skgateway.mjs +856 -0
  18. package/scripts/telegram-catchup-all.sh +136 -0
  19. package/scripts/watch-anthropic-token.sh +117 -0
  20. package/src/skcapstone/__init__.py +1 -1
  21. package/src/skcapstone/_cli_monolith.py +4 -4
  22. package/src/skcapstone/api.py +36 -35
  23. package/src/skcapstone/auction.py +8 -8
  24. package/src/skcapstone/blueprint_registry.py +2 -2
  25. package/src/skcapstone/blueprints/builtins/itil-operations.yaml +40 -0
  26. package/src/skcapstone/brain_first.py +238 -0
  27. package/src/skcapstone/chat.py +4 -4
  28. package/src/skcapstone/cli/__init__.py +2 -0
  29. package/src/skcapstone/cli/agents_spawner.py +5 -2
  30. package/src/skcapstone/cli/chat.py +5 -2
  31. package/src/skcapstone/cli/consciousness.py +5 -2
  32. package/src/skcapstone/cli/daemon.py +116 -41
  33. package/src/skcapstone/cli/itil.py +434 -0
  34. package/src/skcapstone/cli/memory.py +4 -4
  35. package/src/skcapstone/cli/skills_cmd.py +2 -2
  36. package/src/skcapstone/cli/soul.py +5 -2
  37. package/src/skcapstone/cli/status.py +11 -8
  38. package/src/skcapstone/cli/upgrade_cmd.py +7 -4
  39. package/src/skcapstone/cli/watch_cmd.py +9 -6
  40. package/src/skcapstone/config_validator.py +7 -4
  41. package/src/skcapstone/consciousness_config.py +27 -0
  42. package/src/skcapstone/consciousness_loop.py +20 -18
  43. package/src/skcapstone/coordination.py +6 -2
  44. package/src/skcapstone/daemon.py +51 -42
  45. package/src/skcapstone/dashboard.py +8 -8
  46. package/src/skcapstone/defaults/lumina/config/claude-hooks.md +42 -0
  47. package/src/skcapstone/doctor.py +5 -2
  48. package/src/skcapstone/dreaming.py +1440 -0
  49. package/src/skcapstone/emotion_tracker.py +2 -2
  50. package/src/skcapstone/export.py +2 -2
  51. package/src/skcapstone/fuse_mount.py +21 -13
  52. package/src/skcapstone/heartbeat.py +33 -29
  53. package/src/skcapstone/itil.py +1104 -0
  54. package/src/skcapstone/launchd.py +426 -0
  55. package/src/skcapstone/mcp_server.py +306 -4
  56. package/src/skcapstone/mcp_tools/__init__.py +4 -0
  57. package/src/skcapstone/mcp_tools/_helpers.py +2 -2
  58. package/src/skcapstone/mcp_tools/ansible_tools.py +7 -4
  59. package/src/skcapstone/mcp_tools/brain_first_tools.py +90 -0
  60. package/src/skcapstone/mcp_tools/capauth_tools.py +7 -4
  61. package/src/skcapstone/mcp_tools/coord_tools.py +8 -4
  62. package/src/skcapstone/mcp_tools/did_tools.py +9 -6
  63. package/src/skcapstone/mcp_tools/gtd_tools.py +1 -1
  64. package/src/skcapstone/mcp_tools/itil_tools.py +657 -0
  65. package/src/skcapstone/mcp_tools/memory_tools.py +6 -2
  66. package/src/skcapstone/mcp_tools/soul_tools.py +6 -2
  67. package/src/skcapstone/mdns_discovery.py +2 -2
  68. package/src/skcapstone/metrics.py +8 -8
  69. package/src/skcapstone/migrate_memories.py +2 -2
  70. package/src/skcapstone/models.py +14 -0
  71. package/src/skcapstone/onboard.py +137 -14
  72. package/src/skcapstone/peer_directory.py +2 -2
  73. package/src/skcapstone/providers/docker.py +2 -2
  74. package/src/skcapstone/scheduled_tasks.py +107 -0
  75. package/src/skcapstone/service_health.py +83 -4
  76. package/src/skcapstone/sync_watcher.py +2 -2
  77. package/src/skcapstone/systemd.py +17 -0
@@ -0,0 +1,426 @@
1
+ """LaunchD service management for the SKCapstone daemon (macOS).
2
+
3
+ Installs, manages, and queries launchd user agents — the macOS
4
+ equivalent of systemd user services. No root required.
5
+
6
+ Generates plist files dynamically with the correct agent name,
7
+ paths, and environment. Copies them to ~/Library/LaunchAgents/
8
+ and loads via launchctl.
9
+
10
+ Usage:
11
+ from skcapstone.launchd import install_service, service_status
12
+ install_service(agent_name="myagent")
13
+ status = service_status()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import platform
21
+ import plistlib
22
+ import shutil
23
+ import subprocess
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ logger = logging.getLogger("skcapstone.launchd")
29
+
30
+ LABEL_PREFIX = "com.skcapstone"
31
+ LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
32
+
33
+ # Plist definitions: (suffix, program_args, schedule, nice)
34
+ # program_args use {skenv} and {agent} as placeholders
35
+ _SERVICE_DEFS: list[dict] = [
36
+ {
37
+ "suffix": "daemon",
38
+ "args": ["{skenv}/skcapstone", "daemon", "start", "--foreground"],
39
+ "env": {
40
+ "PYTHONUNBUFFERED": "1",
41
+ "OLLAMA_KEEP_ALIVE": "5m",
42
+ "SKCAPSTONE_AGENT": "{agent}",
43
+ },
44
+ "keep_alive": True,
45
+ "throttle": 10,
46
+ "logs": "{logs}/daemon",
47
+ },
48
+ {
49
+ "suffix": "memory-compress",
50
+ "args": ["{skenv}/skcapstone", "memory", "compress"],
51
+ "env": {"PYTHONUNBUFFERED": "1"},
52
+ "calendar": {"Weekday": 0, "Hour": 0, "Minute": 0},
53
+ "nice": 15,
54
+ "logs": "{logs}/memory-compress",
55
+ },
56
+ {
57
+ "suffix": "skcomm-heartbeat",
58
+ "args": ["{skenv}/skcomm", "heartbeat"],
59
+ "env": {},
60
+ "interval": 60,
61
+ "nice": 19,
62
+ "logs": "{logs}/skcomm-heartbeat",
63
+ },
64
+ {
65
+ "suffix": "skcomm-queue-drain",
66
+ "args": ["{skenv}/skcomm", "queue", "drain"],
67
+ "env": {},
68
+ "interval": 120,
69
+ "nice": 19,
70
+ "logs": "{logs}/skcomm-queue-drain",
71
+ },
72
+ ]
73
+
74
+ # Optional services from other repos
75
+ _OPTIONAL_DEFS: list[dict] = [
76
+ {
77
+ "suffix": "skchat-daemon",
78
+ "args": ["{skenv}/skchat", "daemon", "start", "--interval", "5",
79
+ "--log-file", "{home}/.skchat/daemon.log"],
80
+ "env": {"SKCHAT_IDENTITY": "capauth:{agent}@skworld.io"},
81
+ "keep_alive": True,
82
+ "throttle": 5,
83
+ "logs": "{skchat}/launchd",
84
+ "requires_bin": "skchat",
85
+ },
86
+ {
87
+ "suffix": "skcomm-api",
88
+ "args": ["{skenv}/python3", "-m", "uvicorn", "skcomm.api:app",
89
+ "--host", "127.0.0.1", "--port", "9384", "--log-level", "info"],
90
+ "env": {"SKCHAT_IDENTITY": "capauth:{agent}@skworld.io"},
91
+ "keep_alive": True,
92
+ "throttle": 5,
93
+ "logs": "{skcomm}/launchd",
94
+ "requires_bin": "skcomm",
95
+ },
96
+ {
97
+ "suffix": "skcomm-daemon",
98
+ "args": ["{skenv}/skcomm", "daemon", "--all-agents", "--interval", "5"],
99
+ "env": {},
100
+ "keep_alive": True,
101
+ "throttle": 5,
102
+ "logs": "{skcomm}/daemon",
103
+ "requires_bin": "skcomm",
104
+ },
105
+ ]
106
+
107
+
108
+ def _require_macos() -> None:
109
+ """Raise RuntimeError if not running on macOS."""
110
+ if platform.system() != "Darwin":
111
+ raise RuntimeError(
112
+ "launchd is only available on macOS. Use systemd on Linux."
113
+ )
114
+
115
+
116
+ def _skenv_bin() -> str:
117
+ """Return the skenv bin directory."""
118
+ return str(Path.home() / ".skenv" / "bin")
119
+
120
+
121
+ def _expand(s: str, agent: str) -> str:
122
+ """Expand placeholders in a string."""
123
+ home = str(Path.home())
124
+ return (
125
+ s.replace("{skenv}", _skenv_bin())
126
+ .replace("{agent}", agent)
127
+ .replace("{home}", home)
128
+ .replace("{logs}", f"{home}/.skcapstone/logs")
129
+ .replace("{skchat}", f"{home}/.skchat")
130
+ .replace("{skcomm}", f"{home}/.skcomm")
131
+ )
132
+
133
+
134
+ def _build_plist(defn: dict, agent: str) -> dict:
135
+ """Build a plist dict from a service definition."""
136
+ label = f"{LABEL_PREFIX}.{defn['suffix']}"
137
+ skenv = _skenv_bin()
138
+ home = str(Path.home())
139
+
140
+ plist: dict = {
141
+ "Label": label,
142
+ "ProgramArguments": [_expand(a, agent) for a in defn["args"]],
143
+ "EnvironmentVariables": {
144
+ k: _expand(v, agent) for k, v in defn.get("env", {}).items()
145
+ },
146
+ }
147
+
148
+ # Ensure PATH includes skenv and Homebrew
149
+ plist["EnvironmentVariables"]["PATH"] = (
150
+ f"{skenv}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
151
+ )
152
+
153
+ if defn.get("keep_alive"):
154
+ plist["RunAtLoad"] = True
155
+ plist["KeepAlive"] = {"SuccessfulExit": False}
156
+
157
+ if defn.get("throttle"):
158
+ plist["ThrottleInterval"] = defn["throttle"]
159
+
160
+ if defn.get("interval"):
161
+ plist["StartInterval"] = defn["interval"]
162
+
163
+ if defn.get("calendar"):
164
+ plist["StartCalendarInterval"] = defn["calendar"]
165
+
166
+ if defn.get("nice"):
167
+ plist["Nice"] = defn["nice"]
168
+
169
+ logs_base = _expand(defn.get("logs", f"{home}/.skcapstone/logs/misc"), agent)
170
+ plist["StandardOutPath"] = f"{logs_base}.stdout.log"
171
+ plist["StandardErrorPath"] = f"{logs_base}.stderr.log"
172
+
173
+ return plist
174
+
175
+
176
+ def _label(suffix: str) -> str:
177
+ return f"{LABEL_PREFIX}.{suffix}"
178
+
179
+
180
+ def _launchctl_boot(label: str, plist_path: Path, load: bool = True) -> bool:
181
+ """Load or unload a plist via launchctl."""
182
+ uid = os.getuid()
183
+ domain = f"gui/{uid}"
184
+
185
+ if not load:
186
+ # Unload
187
+ r = subprocess.run(
188
+ ["launchctl", "bootout", f"{domain}/{label}"],
189
+ capture_output=True, text=True, timeout=10,
190
+ )
191
+ return r.returncode == 0
192
+
193
+ # Load — try modern bootstrap first, fall back to legacy load
194
+ r = subprocess.run(
195
+ ["launchctl", "bootstrap", domain, str(plist_path)],
196
+ capture_output=True, text=True, timeout=10,
197
+ )
198
+ if r.returncode == 0:
199
+ return True
200
+
201
+ r = subprocess.run(
202
+ ["launchctl", "load", str(plist_path)],
203
+ capture_output=True, text=True, timeout=10,
204
+ )
205
+ return r.returncode == 0
206
+
207
+
208
+ @dataclass
209
+ class ServiceStatus:
210
+ """Status of an SK launchd service."""
211
+ installed: bool = False
212
+ loaded: bool = False
213
+ running: bool = False
214
+ pid: int = 0
215
+ exit_code: int = 0
216
+ label: str = ""
217
+
218
+
219
+ def launchd_available() -> bool:
220
+ """Check if launchd is available (i.e., we're on macOS)."""
221
+ return platform.system() == "Darwin"
222
+
223
+
224
+ def list_available_services(agent: str = "sovereign") -> list[dict]:
225
+ """Return all service definitions, marking which are available.
226
+
227
+ Args:
228
+ agent: Agent name for path expansion.
229
+
230
+ Returns:
231
+ List of dicts with 'suffix', 'label', 'available', 'description'.
232
+ """
233
+ skenv = _skenv_bin()
234
+ services = []
235
+
236
+ for defn in _SERVICE_DEFS:
237
+ services.append({
238
+ "suffix": defn["suffix"],
239
+ "label": _label(defn["suffix"]),
240
+ "available": True,
241
+ "description": defn["suffix"].replace("-", " ").title(),
242
+ })
243
+
244
+ for defn in _OPTIONAL_DEFS:
245
+ req_bin = defn.get("requires_bin")
246
+ available = bool(shutil.which(req_bin, path=skenv)) if req_bin else True
247
+ services.append({
248
+ "suffix": defn["suffix"],
249
+ "label": _label(defn["suffix"]),
250
+ "available": available,
251
+ "description": defn["suffix"].replace("-", " ").title(),
252
+ })
253
+
254
+ return services
255
+
256
+
257
+ def install_service(
258
+ agent_name: str = "sovereign",
259
+ services: Optional[list[str]] = None,
260
+ enable: bool = True,
261
+ start: bool = False,
262
+ ) -> dict:
263
+ """Install launchd user agents for skcapstone.
264
+
265
+ Generates plist files dynamically with the given agent name,
266
+ writes them to ~/Library/LaunchAgents/, and optionally loads them.
267
+
268
+ Args:
269
+ agent_name: Agent name (used in SKCAPSTONE_AGENT env var and paths).
270
+ services: List of service suffixes to install. None = all core services.
271
+ enable: Write plists to LaunchAgents (always True for launchd).
272
+ start: Load/start services immediately after installing.
273
+
274
+ Returns:
275
+ dict with 'installed', 'loaded', 'services' list.
276
+ """
277
+ _require_macos()
278
+ LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
279
+
280
+ # Ensure log directories exist
281
+ log_dirs = [
282
+ Path.home() / ".skcapstone" / "logs",
283
+ Path.home() / ".skchat",
284
+ Path.home() / ".skcomm",
285
+ ]
286
+ for d in log_dirs:
287
+ d.mkdir(parents=True, exist_ok=True)
288
+
289
+ all_defs = _SERVICE_DEFS + _OPTIONAL_DEFS
290
+ if services:
291
+ all_defs = [d for d in all_defs if d["suffix"] in services]
292
+
293
+ result = {"installed": False, "loaded": False, "services": []}
294
+ installed_count = 0
295
+
296
+ for defn in all_defs:
297
+ suffix = defn["suffix"]
298
+ label = _label(suffix)
299
+
300
+ # Skip optional services whose binary isn't installed
301
+ req_bin = defn.get("requires_bin")
302
+ if req_bin and not shutil.which(req_bin, path=_skenv_bin()):
303
+ logger.debug("Skipping %s — %s not found", suffix, req_bin)
304
+ continue
305
+
306
+ plist_data = _build_plist(defn, agent_name)
307
+ plist_path = LAUNCH_AGENTS_DIR / f"{label}.plist"
308
+
309
+ with open(plist_path, "wb") as f:
310
+ plistlib.dump(plist_data, f)
311
+
312
+ svc_result = {"suffix": suffix, "label": label, "installed": True, "loaded": False}
313
+ installed_count += 1
314
+
315
+ if start:
316
+ # Unload first if already loaded
317
+ _launchctl_boot(label, plist_path, load=False)
318
+ loaded = _launchctl_boot(label, plist_path, load=True)
319
+ svc_result["loaded"] = loaded
320
+
321
+ result["services"].append(svc_result)
322
+
323
+ result["installed"] = installed_count > 0
324
+ result["loaded"] = start and all(s["loaded"] for s in result["services"])
325
+ logger.info("Installed %d launchd plist(s) for agent '%s'", installed_count, agent_name)
326
+ return result
327
+
328
+
329
+ def uninstall_service() -> dict:
330
+ """Uninstall all SK launchd user agents.
331
+
332
+ Unloads running services and removes plist files.
333
+
334
+ Returns:
335
+ dict with 'stopped', 'removed' bools and 'services' list.
336
+ """
337
+ _require_macos()
338
+ result = {"stopped": False, "removed": False, "services": []}
339
+ removed = 0
340
+
341
+ for plist_path in sorted(LAUNCH_AGENTS_DIR.glob(f"{LABEL_PREFIX}.*.plist")):
342
+ label = plist_path.stem # e.g., com.skcapstone.daemon
343
+ _launchctl_boot(label, plist_path, load=False)
344
+ plist_path.unlink(missing_ok=True)
345
+ result["services"].append(label)
346
+ removed += 1
347
+
348
+ result["stopped"] = removed > 0
349
+ result["removed"] = removed > 0
350
+ logger.info("Uninstalled %d launchd plist(s)", removed)
351
+ return result
352
+
353
+
354
+ def service_status(suffix: str = "daemon") -> ServiceStatus:
355
+ """Query the status of a specific SK launchd service.
356
+
357
+ Args:
358
+ suffix: Service suffix (e.g., 'daemon', 'skcomm-heartbeat').
359
+
360
+ Returns:
361
+ ServiceStatus with current state.
362
+ """
363
+ _require_macos()
364
+ label = _label(suffix)
365
+ status = ServiceStatus(label=label)
366
+
367
+ plist_path = LAUNCH_AGENTS_DIR / f"{label}.plist"
368
+ status.installed = plist_path.exists()
369
+ if not status.installed:
370
+ return status
371
+
372
+ r = subprocess.run(
373
+ ["launchctl", "list"],
374
+ capture_output=True, text=True, timeout=10,
375
+ )
376
+ for line in r.stdout.splitlines():
377
+ parts = line.split("\t")
378
+ if len(parts) >= 3 and parts[2] == label:
379
+ status.loaded = True
380
+ try:
381
+ pid = int(parts[0])
382
+ if pid > 0:
383
+ status.running = True
384
+ status.pid = pid
385
+ except (ValueError, IndexError):
386
+ pass
387
+ try:
388
+ status.exit_code = int(parts[1])
389
+ except (ValueError, IndexError):
390
+ pass
391
+ break
392
+
393
+ return status
394
+
395
+
396
+ def service_logs(suffix: str = "daemon", lines: int = 50) -> str:
397
+ """Get recent logs for an SK launchd service.
398
+
399
+ Reads the stdout/stderr log files written by launchd.
400
+
401
+ Args:
402
+ suffix: Service suffix.
403
+ lines: Number of tail lines.
404
+
405
+ Returns:
406
+ Combined log output.
407
+ """
408
+ _require_macos()
409
+ logs_dir = Path.home() / ".skcapstone" / "logs"
410
+ stdout_log = logs_dir / f"{suffix}.stdout.log"
411
+ stderr_log = logs_dir / f"{suffix}.stderr.log"
412
+
413
+ output_parts = []
414
+ for log_path in (stdout_log, stderr_log):
415
+ if log_path.exists():
416
+ try:
417
+ r = subprocess.run(
418
+ ["tail", "-n", str(lines), str(log_path)],
419
+ capture_output=True, text=True, timeout=5,
420
+ )
421
+ if r.stdout.strip():
422
+ output_parts.append(f"--- {log_path.name} ---\n{r.stdout}")
423
+ except Exception as exc:
424
+ logger.warning("Failed to read launchd log %s: %s", log_path, exc)
425
+
426
+ return "\n".join(output_parts) if output_parts else f"No logs found in {logs_dir}"