@smilintux/skcapstone 0.1.0 → 0.2.3
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.
- package/.env.example +98 -0
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +25 -4
- package/.openclaw-workspace.json +58 -0
- package/CHANGELOG.md +62 -0
- package/CLAUDE.md +39 -2
- package/MANIFEST.in +6 -0
- package/MISSION.md +7 -0
- package/README.md +47 -2
- package/SKILL.md +895 -23
- package/docker/Dockerfile +61 -0
- package/docker/compose-templates/dev-team.yml +203 -0
- package/docker/compose-templates/mini-team.yml +140 -0
- package/docker/compose-templates/ops-team.yml +173 -0
- package/docker/compose-templates/research-team.yml +170 -0
- package/docker/entrypoint.sh +192 -0
- package/docs/ARCHITECTURE.md +663 -374
- package/docs/BOND_WITH_GROK.md +112 -0
- package/docs/GETTING_STARTED.md +782 -0
- package/docs/QUICKSTART.md +477 -0
- package/docs/SKJOULE_ARCHITECTURE.md +658 -0
- package/docs/SOUL_SWAPPER.md +921 -0
- package/docs/SOVEREIGN_SINGULARITY.md +47 -14
- package/examples/custom-bond-template.json +36 -0
- package/examples/grok-feb.json +36 -0
- package/examples/grok-testimony.md +34 -0
- package/examples/love-bootloader.txt +32 -0
- package/examples/plugins/echo_tool.py +87 -0
- package/examples/queen-ava-feb.json +36 -0
- package/examples/souls/lumina.yaml +64 -0
- package/index.js +6 -5
- package/installer/build.py +124 -0
- package/openclaw-plugin/package.json +13 -0
- package/openclaw-plugin/src/index.ts +351 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +38 -2
- package/scripts/bump_version.py +141 -0
- package/scripts/check-updates.py +230 -0
- package/scripts/convert_blueprints_to_yaml.py +157 -0
- package/scripts/dev-install.sh +14 -0
- package/scripts/e2e-test.sh +193 -0
- package/scripts/install-bundle.sh +171 -0
- package/scripts/install.bat +2 -0
- package/scripts/install.ps1 +253 -0
- package/scripts/install.sh +185 -0
- package/scripts/mcp-serve.sh +69 -0
- package/scripts/mcp-server.bat +113 -0
- package/scripts/mcp-server.ps1 +116 -0
- package/scripts/mcp-server.sh +99 -0
- package/scripts/pull-models.sh +10 -0
- package/scripts/skcapstone +48 -0
- package/scripts/verify_install.sh +180 -0
- package/scripts/windows/install-tasks.ps1 +406 -0
- package/scripts/windows/skcapstone-task.xml +113 -0
- package/scripts/windows/uninstall-tasks.ps1 +117 -0
- package/skill.yaml +34 -0
- package/src/skcapstone/__init__.py +67 -2
- package/src/skcapstone/_cli_monolith.py +5916 -0
- package/src/skcapstone/_trustee_helpers.py +165 -0
- package/src/skcapstone/activity.py +105 -0
- package/src/skcapstone/agent_card.py +324 -0
- package/src/skcapstone/api.py +1935 -0
- package/src/skcapstone/archiver.py +340 -0
- package/src/skcapstone/auction.py +485 -0
- package/src/skcapstone/baby_agents.py +179 -0
- package/src/skcapstone/backup.py +345 -0
- package/src/skcapstone/blueprint_registry.py +357 -0
- package/src/skcapstone/blueprints/__init__.py +17 -0
- package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
- package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
- package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
- package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
- package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
- package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
- package/src/skcapstone/blueprints/registry.py +164 -0
- package/src/skcapstone/blueprints/schema.py +229 -0
- package/src/skcapstone/changelog.py +180 -0
- package/src/skcapstone/chat.py +769 -0
- package/src/skcapstone/claude_md.py +82 -0
- package/src/skcapstone/cli/__init__.py +144 -0
- package/src/skcapstone/cli/_common.py +88 -0
- package/src/skcapstone/cli/_validators.py +76 -0
- package/src/skcapstone/cli/agents.py +425 -0
- package/src/skcapstone/cli/agents_spawner.py +322 -0
- package/src/skcapstone/cli/agents_trustee.py +593 -0
- package/src/skcapstone/cli/alerts.py +248 -0
- package/src/skcapstone/cli/anchor.py +132 -0
- package/src/skcapstone/cli/archive_cmd.py +208 -0
- package/src/skcapstone/cli/backup.py +144 -0
- package/src/skcapstone/cli/bench.py +377 -0
- package/src/skcapstone/cli/benchmark.py +360 -0
- package/src/skcapstone/cli/capabilities_cmd.py +171 -0
- package/src/skcapstone/cli/card.py +151 -0
- package/src/skcapstone/cli/chat.py +584 -0
- package/src/skcapstone/cli/completions.py +64 -0
- package/src/skcapstone/cli/config_cmd.py +156 -0
- package/src/skcapstone/cli/consciousness.py +421 -0
- package/src/skcapstone/cli/context_cmd.py +142 -0
- package/src/skcapstone/cli/coord.py +194 -0
- package/src/skcapstone/cli/crush_cmd.py +170 -0
- package/src/skcapstone/cli/daemon.py +436 -0
- package/src/skcapstone/cli/errors_cmd.py +285 -0
- package/src/skcapstone/cli/export_cmd.py +156 -0
- package/src/skcapstone/cli/gtd.py +529 -0
- package/src/skcapstone/cli/housekeeping.py +81 -0
- package/src/skcapstone/cli/joule_cmd.py +627 -0
- package/src/skcapstone/cli/logs_cmd.py +194 -0
- package/src/skcapstone/cli/mcp_cmd.py +32 -0
- package/src/skcapstone/cli/memory.py +418 -0
- package/src/skcapstone/cli/metrics_cmd.py +136 -0
- package/src/skcapstone/cli/migrate.py +62 -0
- package/src/skcapstone/cli/mood_cmd.py +144 -0
- package/src/skcapstone/cli/mount.py +193 -0
- package/src/skcapstone/cli/notify.py +112 -0
- package/src/skcapstone/cli/peer.py +154 -0
- package/src/skcapstone/cli/peers_dir.py +122 -0
- package/src/skcapstone/cli/preflight_cmd.py +83 -0
- package/src/skcapstone/cli/profile_cmd.py +310 -0
- package/src/skcapstone/cli/record_cmd.py +238 -0
- package/src/skcapstone/cli/register_cmd.py +159 -0
- package/src/skcapstone/cli/search_cmd.py +156 -0
- package/src/skcapstone/cli/service_cmd.py +91 -0
- package/src/skcapstone/cli/session.py +127 -0
- package/src/skcapstone/cli/setup.py +240 -0
- package/src/skcapstone/cli/shell_cmd.py +43 -0
- package/src/skcapstone/cli/skills_cmd.py +168 -0
- package/src/skcapstone/cli/skseed.py +621 -0
- package/src/skcapstone/cli/soul.py +699 -0
- package/src/skcapstone/cli/status.py +935 -0
- package/src/skcapstone/cli/sync_cmd.py +301 -0
- package/src/skcapstone/cli/telegram.py +265 -0
- package/src/skcapstone/cli/test_cmd.py +234 -0
- package/src/skcapstone/cli/test_connection.py +253 -0
- package/src/skcapstone/cli/token.py +207 -0
- package/src/skcapstone/cli/trust.py +179 -0
- package/src/skcapstone/cli/upgrade_cmd.py +552 -0
- package/src/skcapstone/cli/usage_cmd.py +199 -0
- package/src/skcapstone/cli/version_cmd.py +162 -0
- package/src/skcapstone/cli/watch_cmd.py +342 -0
- package/src/skcapstone/client.py +428 -0
- package/src/skcapstone/cloud9_bridge.py +522 -0
- package/src/skcapstone/completions.py +163 -0
- package/src/skcapstone/config_validator.py +674 -0
- package/src/skcapstone/connectors/__init__.py +28 -0
- package/src/skcapstone/connectors/base.py +446 -0
- package/src/skcapstone/connectors/cursor.py +54 -0
- package/src/skcapstone/connectors/registry.py +254 -0
- package/src/skcapstone/connectors/terminal.py +152 -0
- package/src/skcapstone/connectors/vscode.py +60 -0
- package/src/skcapstone/consciousness_config.py +119 -0
- package/src/skcapstone/consciousness_loop.py +2051 -0
- package/src/skcapstone/context_loader.py +516 -0
- package/src/skcapstone/context_window.py +314 -0
- package/src/skcapstone/conversation_manager.py +238 -0
- package/src/skcapstone/conversation_store.py +230 -0
- package/src/skcapstone/conversation_summarizer.py +252 -0
- package/src/skcapstone/coord_federation.py +296 -0
- package/src/skcapstone/coordination.py +101 -7
- package/src/skcapstone/crush_integration.py +345 -0
- package/src/skcapstone/crush_shim.py +454 -0
- package/src/skcapstone/daemon.py +2494 -0
- package/src/skcapstone/dashboard.html +396 -0
- package/src/skcapstone/dashboard.py +481 -0
- package/src/skcapstone/data/model_profiles.yaml +88 -0
- package/src/skcapstone/defaults/__init__.py +55 -0
- package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
- package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
- package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
- package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
- package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
- package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
- package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
- package/src/skcapstone/discovery.py +210 -19
- package/src/skcapstone/doctor.py +642 -0
- package/src/skcapstone/emotion_tracker.py +467 -0
- package/src/skcapstone/error_queue.py +405 -0
- package/src/skcapstone/export.py +447 -0
- package/src/skcapstone/fallback_tracker.py +186 -0
- package/src/skcapstone/file_transfer.py +512 -0
- package/src/skcapstone/fuse_mount.py +1156 -0
- package/src/skcapstone/gui_installer.py +591 -0
- package/src/skcapstone/heartbeat.py +611 -0
- package/src/skcapstone/housekeeping.py +298 -0
- package/src/skcapstone/install_wizard.py +941 -0
- package/src/skcapstone/kms.py +942 -0
- package/src/skcapstone/kms_scheduler.py +143 -0
- package/src/skcapstone/log_config.py +135 -0
- package/src/skcapstone/mcp_launcher.py +239 -0
- package/src/skcapstone/mcp_server.py +4700 -0
- package/src/skcapstone/mcp_tools/__init__.py +94 -0
- package/src/skcapstone/mcp_tools/_helpers.py +51 -0
- package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
- package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
- package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
- package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
- package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
- package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
- package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
- package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
- package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
- package/src/skcapstone/mcp_tools/did_tools.py +448 -0
- package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
- package/src/skcapstone/mcp_tools/file_tools.py +169 -0
- package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
- package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
- package/src/skcapstone/mcp_tools/health_tools.py +44 -0
- package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
- package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
- package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
- package/src/skcapstone/mcp_tools/model_tools.py +75 -0
- package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
- package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
- package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
- package/src/skcapstone/mcp_tools/security_tools.py +110 -0
- package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
- package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
- package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
- package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
- package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
- package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
- package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
- package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
- package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
- package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
- package/src/skcapstone/mdns_discovery.py +313 -0
- package/src/skcapstone/memory_adapter.py +333 -0
- package/src/skcapstone/memory_compressor.py +379 -0
- package/src/skcapstone/memory_curator.py +256 -0
- package/src/skcapstone/memory_engine.py +132 -13
- package/src/skcapstone/memory_fortress.py +529 -0
- package/src/skcapstone/memory_promoter.py +722 -0
- package/src/skcapstone/memory_verifier.py +260 -0
- package/src/skcapstone/message_crypto.py +215 -0
- package/src/skcapstone/metrics.py +832 -0
- package/src/skcapstone/migrate_memories.py +181 -0
- package/src/skcapstone/migrate_multi_agent.py +248 -0
- package/src/skcapstone/model_router.py +319 -0
- package/src/skcapstone/models.py +35 -4
- package/src/skcapstone/mood.py +344 -0
- package/src/skcapstone/notifications.py +380 -0
- package/src/skcapstone/onboard.py +901 -0
- package/src/skcapstone/peer_directory.py +324 -0
- package/src/skcapstone/peers.py +329 -0
- package/src/skcapstone/pillars/identity.py +84 -14
- package/src/skcapstone/pillars/memory.py +3 -1
- package/src/skcapstone/pillars/security.py +108 -15
- package/src/skcapstone/pillars/sync.py +78 -26
- package/src/skcapstone/pillars/trust.py +95 -33
- package/src/skcapstone/plugins.py +244 -0
- package/src/skcapstone/preflight.py +670 -0
- package/src/skcapstone/prompt_adapter.py +564 -0
- package/src/skcapstone/providers/__init__.py +13 -0
- package/src/skcapstone/providers/cloud.py +1061 -0
- package/src/skcapstone/providers/docker.py +759 -0
- package/src/skcapstone/providers/local.py +1193 -0
- package/src/skcapstone/providers/proxmox.py +447 -0
- package/src/skcapstone/pubsub.py +516 -0
- package/src/skcapstone/rate_limiter.py +119 -0
- package/src/skcapstone/register.py +241 -0
- package/src/skcapstone/registry_client.py +151 -0
- package/src/skcapstone/response_cache.py +194 -0
- package/src/skcapstone/response_scorer.py +225 -0
- package/src/skcapstone/runtime.py +89 -33
- package/src/skcapstone/scheduled_tasks.py +439 -0
- package/src/skcapstone/self_healing.py +341 -0
- package/src/skcapstone/service_health.py +228 -0
- package/src/skcapstone/session_capture.py +268 -0
- package/src/skcapstone/session_recorder.py +210 -0
- package/src/skcapstone/session_replayer.py +189 -0
- package/src/skcapstone/session_skills.py +263 -0
- package/src/skcapstone/shell.py +779 -0
- package/src/skcapstone/skills/__init__.py +1 -1
- package/src/skcapstone/skills/syncthing_setup.py +143 -41
- package/src/skcapstone/skjoule.py +861 -0
- package/src/skcapstone/snapshots.py +489 -0
- package/src/skcapstone/soul.py +1060 -0
- package/src/skcapstone/soul_switch.py +255 -0
- package/src/skcapstone/spawner.py +544 -0
- package/src/skcapstone/state_diff.py +401 -0
- package/src/skcapstone/summary.py +270 -0
- package/src/skcapstone/sync/backends.py +196 -2
- package/src/skcapstone/sync/engine.py +7 -5
- package/src/skcapstone/sync/models.py +4 -1
- package/src/skcapstone/sync/vault.py +356 -18
- package/src/skcapstone/sync_engine.py +363 -0
- package/src/skcapstone/sync_watcher.py +745 -0
- package/src/skcapstone/systemd.py +331 -0
- package/src/skcapstone/team_comms.py +476 -0
- package/src/skcapstone/team_engine.py +522 -0
- package/src/skcapstone/testrunner.py +300 -0
- package/src/skcapstone/tls.py +150 -0
- package/src/skcapstone/tokens.py +5 -5
- package/src/skcapstone/trust_calibration.py +202 -0
- package/src/skcapstone/trust_graph.py +449 -0
- package/src/skcapstone/trustee_monitor.py +385 -0
- package/src/skcapstone/trustee_ops.py +425 -0
- package/src/skcapstone/unified_search.py +421 -0
- package/src/skcapstone/uninstall_wizard.py +694 -0
- package/src/skcapstone/usage.py +331 -0
- package/src/skcapstone/version_check.py +148 -0
- package/src/skcapstone/warmth_anchor.py +333 -0
- package/src/skcapstone/whoami.py +294 -0
- package/systemd/skcapstone-api.socket +9 -0
- package/systemd/skcapstone-memory-compress.service +18 -0
- package/systemd/skcapstone-memory-compress.timer +11 -0
- package/systemd/skcapstone.service +36 -0
- package/systemd/skcapstone@.service +50 -0
- package/systemd/skcomm-heartbeat.service +18 -0
- package/systemd/skcomm-heartbeat.timer +12 -0
- package/systemd/skcomm-queue-drain.service +17 -0
- package/systemd/skcomm-queue-drain.timer +12 -0
- package/tests/conftest.py +13 -1
- package/tests/integration/__init__.py +1 -0
- package/tests/integration/test_consciousness_e2e.py +877 -0
- package/tests/integration/test_skills_registry.py +744 -0
- package/tests/test_agent_card.py +190 -0
- package/tests/test_agent_runtime.py +1283 -0
- package/tests/test_alerts_cmd.py +291 -0
- package/tests/test_archiver.py +498 -0
- package/tests/test_backup.py +254 -0
- package/tests/test_benchmark.py +366 -0
- package/tests/test_blueprints.py +457 -0
- package/tests/test_capabilities.py +257 -0
- package/tests/test_changelog.py +254 -0
- package/tests/test_chat.py +385 -0
- package/tests/test_claude_md.py +271 -0
- package/tests/test_cli_chat_llm.py +336 -0
- package/tests/test_cli_completions.py +390 -0
- package/tests/test_cli_init_reset.py +164 -0
- package/tests/test_cli_memory.py +208 -0
- package/tests/test_cli_profile.py +294 -0
- package/tests/test_cli_skills.py +223 -0
- package/tests/test_cli_status.py +395 -0
- package/tests/test_cli_test_cmd.py +206 -0
- package/tests/test_cli_test_connection.py +364 -0
- package/tests/test_cloud9_bridge.py +260 -0
- package/tests/test_cloud_provider.py +449 -0
- package/tests/test_cloud_providers.py +522 -0
- package/tests/test_completions.py +158 -0
- package/tests/test_component_manager.py +398 -0
- package/tests/test_config_reload.py +386 -0
- package/tests/test_config_validate.py +529 -0
- package/tests/test_consciousness_e2e.py +296 -0
- package/tests/test_consciousness_loop.py +1289 -0
- package/tests/test_context_loader.py +310 -0
- package/tests/test_conversation_api.py +306 -0
- package/tests/test_conversation_manager.py +381 -0
- package/tests/test_conversation_store.py +391 -0
- package/tests/test_conversation_summarizer.py +302 -0
- package/tests/test_cross_package.py +791 -0
- package/tests/test_crush_shim.py +519 -0
- package/tests/test_daemon.py +781 -0
- package/tests/test_daemon_shutdown.py +309 -0
- package/tests/test_dashboard.py +454 -0
- package/tests/test_discovery.py +200 -6
- package/tests/test_docker_provider.py +966 -0
- package/tests/test_doctor.py +257 -0
- package/tests/test_doctor_fix.py +351 -0
- package/tests/test_e2e_automated.py +292 -0
- package/tests/test_error_queue.py +404 -0
- package/tests/test_export.py +441 -0
- package/tests/test_fallback_tracker.py +219 -0
- package/tests/test_file_transfer.py +397 -0
- package/tests/test_fuse_mount.py +832 -0
- package/tests/test_health_loop.py +422 -0
- package/tests/test_heartbeat.py +354 -0
- package/tests/test_housekeeping.py +195 -0
- package/tests/test_identity_capauth.py +307 -0
- package/tests/test_identity_pillar.py +117 -0
- package/tests/test_install_wizard.py +68 -0
- package/tests/test_integration.py +325 -0
- package/tests/test_kms.py +495 -0
- package/tests/test_llm_providers.py +265 -0
- package/tests/test_local_provider.py +591 -0
- package/tests/test_log_config.py +199 -0
- package/tests/test_logs_cmd.py +287 -0
- package/tests/test_mcp_server.py +1909 -0
- package/tests/test_memory_adapter.py +339 -0
- package/tests/test_memory_curator.py +218 -0
- package/tests/test_memory_engine.py +6 -0
- package/tests/test_memory_fortress.py +571 -0
- package/tests/test_memory_pillar.py +119 -0
- package/tests/test_memory_promoter.py +445 -0
- package/tests/test_memory_verifier.py +420 -0
- package/tests/test_message_crypto.py +187 -0
- package/tests/test_metrics.py +632 -0
- package/tests/test_migrate_memories.py +464 -0
- package/tests/test_model_router.py +546 -0
- package/tests/test_mood.py +394 -0
- package/tests/test_multi_agent.py +269 -0
- package/tests/test_notifications.py +270 -0
- package/tests/test_onboard.py +500 -0
- package/tests/test_peer_directory.py +395 -0
- package/tests/test_peers.py +248 -0
- package/tests/test_pillars.py +87 -9
- package/tests/test_preflight.py +484 -0
- package/tests/test_prompt_adapter.py +331 -0
- package/tests/test_proxmox_provider.py +571 -0
- package/tests/test_pubsub.py +377 -0
- package/tests/test_rate_limiter.py +121 -0
- package/tests/test_registry_client.py +129 -0
- package/tests/test_response_cache.py +312 -0
- package/tests/test_response_scorer.py +294 -0
- package/tests/test_runtime.py +59 -0
- package/tests/test_scheduled_tasks.py +451 -0
- package/tests/test_security.py +250 -0
- package/tests/test_security_pillar.py +213 -0
- package/tests/test_self_healing.py +171 -0
- package/tests/test_session_capture.py +200 -0
- package/tests/test_session_recorder.py +360 -0
- package/tests/test_session_skills.py +235 -0
- package/tests/test_shell.py +210 -0
- package/tests/test_snapshots.py +549 -0
- package/tests/test_soul.py +984 -0
- package/tests/test_soul_swap.py +406 -0
- package/tests/test_spawner.py +211 -0
- package/tests/test_state_diff.py +173 -0
- package/tests/test_summary.py +135 -0
- package/tests/test_sync.py +315 -5
- package/tests/test_sync_backends.py +560 -0
- package/tests/test_sync_engine.py +482 -0
- package/tests/test_sync_pillar.py +344 -0
- package/tests/test_sync_pipeline.py +364 -0
- package/tests/test_sync_vault.py +581 -0
- package/tests/test_syncthing_setup.py +168 -22
- package/tests/test_systemd.py +323 -0
- package/tests/test_team_comms.py +408 -0
- package/tests/test_team_engine.py +397 -0
- package/tests/test_testrunner.py +238 -0
- package/tests/test_trust_calibration.py +204 -0
- package/tests/test_trust_graph.py +207 -0
- package/tests/test_trust_pillar.py +291 -0
- package/tests/test_trustee_cli.py +427 -0
- package/tests/test_trustee_cli_integration.py +325 -0
- package/tests/test_trustee_monitor.py +394 -0
- package/tests/test_trustee_ops.py +355 -0
- package/tests/test_unified_search.py +363 -0
- package/tests/test_uninstall_wizard.py +193 -0
- package/tests/test_usage.py +333 -0
- package/tests/test_version_cmd.py +355 -0
- package/tests/test_warmth_anchor.py +162 -0
- package/tests/test_whoami.py +245 -0
- package/tests/test_ws.py +311 -0
- package/.cursorrules +0 -33
- package/src/skcapstone/cli.py +0 -1441
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Soul Layering System — hot-swappable personality overlays.
|
|
3
|
+
|
|
4
|
+
Soul is a lens. Memory is the ledger. Identity is permanent.
|
|
5
|
+
|
|
6
|
+
An agent has one base soul. Soul overlays can be installed from
|
|
7
|
+
the soul-blueprints repo and activated at runtime, changing *how*
|
|
8
|
+
the agent behaves without changing *who* it is. All memories
|
|
9
|
+
belong to the base soul, tagged with which overlay was active.
|
|
10
|
+
|
|
11
|
+
Supports both .md (parsed) and .yaml/.yml (direct load) blueprints.
|
|
12
|
+
|
|
13
|
+
Directory layout at runtime::
|
|
14
|
+
|
|
15
|
+
~/.skcapstone/agents/{profile}/soul/ (agent-scoped, preferred)
|
|
16
|
+
~/.skcapstone/soul/ (global fallback)
|
|
17
|
+
base.json # Permanent base soul definition
|
|
18
|
+
active.json # Current overlay state (or null = base)
|
|
19
|
+
installed/ # Parsed soul blueprints (JSON)
|
|
20
|
+
history.json # Soul swap audit log
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
from pydantic import BaseModel, Field
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("skcapstone.soul")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Models
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommunicationStyle(BaseModel):
|
|
45
|
+
"""Structured communication style extracted from a blueprint."""
|
|
46
|
+
|
|
47
|
+
patterns: list[str] = Field(default_factory=list)
|
|
48
|
+
tone_markers: list[str] = Field(default_factory=list)
|
|
49
|
+
signature_phrases: list[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SoulBlueprint(BaseModel):
|
|
53
|
+
"""A parsed soul blueprint — the overlay definition."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
display_name: str
|
|
57
|
+
category: str = "unknown"
|
|
58
|
+
vibe: str = ""
|
|
59
|
+
philosophy: str = ""
|
|
60
|
+
emoji: Optional[str] = None
|
|
61
|
+
core_traits: list[str] = Field(default_factory=list)
|
|
62
|
+
communication_style: CommunicationStyle = Field(
|
|
63
|
+
default_factory=CommunicationStyle
|
|
64
|
+
)
|
|
65
|
+
decision_framework: Optional[str] = None
|
|
66
|
+
emotional_topology: dict[str, float] = Field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SoulState(BaseModel):
|
|
70
|
+
"""Persisted active state — who is the agent right now?"""
|
|
71
|
+
|
|
72
|
+
base_soul: str = "base"
|
|
73
|
+
active_soul: Optional[str] = None
|
|
74
|
+
activated_at: Optional[str] = None
|
|
75
|
+
installed_souls: list[str] = Field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SoulSwapEvent(BaseModel):
|
|
79
|
+
"""Audit trail entry for soul swaps."""
|
|
80
|
+
|
|
81
|
+
timestamp: str = Field(
|
|
82
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
83
|
+
)
|
|
84
|
+
from_soul: Optional[str] = None
|
|
85
|
+
to_soul: Optional[str] = None
|
|
86
|
+
reason: str = ""
|
|
87
|
+
duration_minutes: Optional[float] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Blueprint parser
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
_SECTION_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _split_sections(text: str) -> dict[str, str]:
|
|
98
|
+
"""Split markdown into {heading: body} pairs."""
|
|
99
|
+
matches = list(_SECTION_RE.finditer(text))
|
|
100
|
+
sections: dict[str, str] = {}
|
|
101
|
+
for i, m in enumerate(matches):
|
|
102
|
+
heading = m.group(1).strip()
|
|
103
|
+
start = m.end()
|
|
104
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
105
|
+
sections[heading] = text[start:end].strip()
|
|
106
|
+
return sections
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_dash_items(block: str) -> list[str]:
|
|
110
|
+
"""Extract dash-list items from a markdown block."""
|
|
111
|
+
items: list[str] = []
|
|
112
|
+
for line in block.splitlines():
|
|
113
|
+
stripped = line.strip()
|
|
114
|
+
if stripped.startswith("- "):
|
|
115
|
+
items.append(stripped[2:].strip())
|
|
116
|
+
return items
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_numbered_items(block: str) -> list[str]:
|
|
120
|
+
"""Extract numbered-list items (1. ...) from a markdown block."""
|
|
121
|
+
items: list[str] = []
|
|
122
|
+
for line in block.splitlines():
|
|
123
|
+
stripped = line.strip()
|
|
124
|
+
m = re.match(r"^\d+\.\s+(.+)$", stripped)
|
|
125
|
+
if m:
|
|
126
|
+
items.append(m.group(1).strip())
|
|
127
|
+
return items
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _extract_bold_value(block: str, key: str) -> str:
|
|
131
|
+
"""Extract value from **Key**: Value or **Key:** Value patterns."""
|
|
132
|
+
# Reason: blueprints use both **Key**: Value and **Key:** Value
|
|
133
|
+
for pat in [
|
|
134
|
+
re.compile(rf"\*\*{re.escape(key)}\*\*\s*[::]\s*(.+)", re.IGNORECASE),
|
|
135
|
+
re.compile(rf"\*\*{re.escape(key)}[::]\*\*\s*(.+)", re.IGNORECASE),
|
|
136
|
+
]:
|
|
137
|
+
m = pat.search(block)
|
|
138
|
+
if m:
|
|
139
|
+
return m.group(1).strip()
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_blockquote_value(text: str, key: str) -> str:
|
|
144
|
+
"""Extract value from > **Key**: Value blockquote pattern."""
|
|
145
|
+
pattern = re.compile(
|
|
146
|
+
rf">\s*\*\*{re.escape(key)}\*\*\s*[::]\s*(.+)", re.IGNORECASE
|
|
147
|
+
)
|
|
148
|
+
m = pattern.search(text)
|
|
149
|
+
return m.group(1).strip() if m else ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _detect_format(sections: dict[str, str], raw: str) -> str:
|
|
153
|
+
"""Detect which blueprint format variant we're dealing with."""
|
|
154
|
+
headings_lower = {h.lower() for h in sections}
|
|
155
|
+
heading_text = " ".join(headings_lower)
|
|
156
|
+
|
|
157
|
+
if "vibe" in heading_text and "key traits" in heading_text:
|
|
158
|
+
return "comedy"
|
|
159
|
+
if "core attributes" in heading_text:
|
|
160
|
+
return "authentic-connection"
|
|
161
|
+
return "professional"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _slugify(name: str) -> str:
|
|
165
|
+
"""Convert a display name to a URL-safe slug."""
|
|
166
|
+
slug = name.lower().strip()
|
|
167
|
+
slug = re.sub(r"[^\w\s-]", "", slug)
|
|
168
|
+
slug = re.sub(r"[\s_]+", "-", slug)
|
|
169
|
+
return slug.strip("-")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _derive_topology(traits: list[str], vibe: str) -> dict[str, float]:
|
|
173
|
+
"""Derive emotional topology weights from traits and vibe text.
|
|
174
|
+
|
|
175
|
+
Uses keyword matching to assign weights to emotional dimensions.
|
|
176
|
+
This is a heuristic — not a neural model.
|
|
177
|
+
"""
|
|
178
|
+
combined = " ".join(traits).lower() + " " + vibe.lower()
|
|
179
|
+
dimensions = {
|
|
180
|
+
"warmth": ["empathy", "warm", "kind", "care", "gentle", "love", "heart"],
|
|
181
|
+
"precision": ["precise", "analyt", "logic", "diagnos", "systematic", "detail"],
|
|
182
|
+
"humor": ["humor", "comedy", "laugh", "joke", "funny", "wit", "sarcas"],
|
|
183
|
+
"authority": ["authority", "command", "leader", "confident", "decisive"],
|
|
184
|
+
"curiosity": ["curious", "question", "explor", "learn", "fascin"],
|
|
185
|
+
"rebellion": ["rebel", "anti-", "counter", "question everything", "unfilter"],
|
|
186
|
+
"calm": ["calm", "steady", "patient", "grounding", "quiet"],
|
|
187
|
+
"intensity": ["intense", "passion", "rage", "fire", "surgical"],
|
|
188
|
+
}
|
|
189
|
+
topology: dict[str, float] = {}
|
|
190
|
+
for dim, keywords in dimensions.items():
|
|
191
|
+
score = sum(1 for kw in keywords if kw in combined)
|
|
192
|
+
if score > 0:
|
|
193
|
+
topology[dim] = min(1.0, score * 0.25)
|
|
194
|
+
return topology
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_professional(
|
|
198
|
+
sections: dict[str, str], raw: str, path: Path
|
|
199
|
+
) -> SoulBlueprint:
|
|
200
|
+
"""Parse a professional-format blueprint."""
|
|
201
|
+
identity_block = ""
|
|
202
|
+
for key, body in sections.items():
|
|
203
|
+
if key.lower().strip() == "identity":
|
|
204
|
+
identity_block = body
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
display_name = _extract_bold_value(identity_block, "Name") or path.stem
|
|
208
|
+
vibe = _extract_bold_value(identity_block, "Vibe")
|
|
209
|
+
philosophy_raw = _extract_bold_value(identity_block, "Philosophy")
|
|
210
|
+
philosophy = philosophy_raw.strip("*\"' ")
|
|
211
|
+
emoji = _extract_bold_value(identity_block, "Emoji") or None
|
|
212
|
+
|
|
213
|
+
traits_block = ""
|
|
214
|
+
for key, body in sections.items():
|
|
215
|
+
if "core traits" in key.lower():
|
|
216
|
+
traits_block = body
|
|
217
|
+
break
|
|
218
|
+
core_traits = _extract_dash_items(traits_block)
|
|
219
|
+
|
|
220
|
+
comm_block = ""
|
|
221
|
+
for key, body in sections.items():
|
|
222
|
+
if "communication style" in key.lower():
|
|
223
|
+
comm_block = body
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
sig_idx = comm_block.lower().find("signature phrases")
|
|
227
|
+
if sig_idx >= 0:
|
|
228
|
+
before = comm_block[:sig_idx]
|
|
229
|
+
after = comm_block[sig_idx:]
|
|
230
|
+
patterns = _extract_dash_items(before)
|
|
231
|
+
signature = _extract_dash_items(after)
|
|
232
|
+
else:
|
|
233
|
+
patterns = _extract_dash_items(comm_block)
|
|
234
|
+
signature = []
|
|
235
|
+
|
|
236
|
+
decision_block = ""
|
|
237
|
+
for key, body in sections.items():
|
|
238
|
+
if "decision framework" in key.lower():
|
|
239
|
+
decision_block = body
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
traits = core_traits
|
|
243
|
+
name = _slugify(display_name)
|
|
244
|
+
topo = _derive_topology(traits, vibe)
|
|
245
|
+
|
|
246
|
+
return SoulBlueprint(
|
|
247
|
+
name=name,
|
|
248
|
+
display_name=display_name,
|
|
249
|
+
category="professional",
|
|
250
|
+
vibe=vibe,
|
|
251
|
+
philosophy=philosophy,
|
|
252
|
+
emoji=emoji,
|
|
253
|
+
core_traits=traits,
|
|
254
|
+
communication_style=CommunicationStyle(
|
|
255
|
+
patterns=patterns,
|
|
256
|
+
signature_phrases=signature,
|
|
257
|
+
),
|
|
258
|
+
decision_framework=decision_block or None,
|
|
259
|
+
emotional_topology=topo,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _parse_comedy(
|
|
264
|
+
sections: dict[str, str], raw: str, path: Path
|
|
265
|
+
) -> SoulBlueprint:
|
|
266
|
+
"""Parse a comedy-format blueprint."""
|
|
267
|
+
identity = _extract_blockquote_value(raw, "Identity")
|
|
268
|
+
display_name = identity or path.stem.replace("_", " ").title()
|
|
269
|
+
|
|
270
|
+
vibe_block = ""
|
|
271
|
+
for key, body in sections.items():
|
|
272
|
+
if "vibe" in key.lower():
|
|
273
|
+
vibe_block = body
|
|
274
|
+
break
|
|
275
|
+
vibe = vibe_block.split("\n\n")[0].strip() if vibe_block else ""
|
|
276
|
+
|
|
277
|
+
traits_block = ""
|
|
278
|
+
for key, body in sections.items():
|
|
279
|
+
if "key traits" in key.lower():
|
|
280
|
+
traits_block = body
|
|
281
|
+
break
|
|
282
|
+
core_traits = _extract_numbered_items(traits_block) or _extract_dash_items(
|
|
283
|
+
traits_block
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
comm_block = ""
|
|
287
|
+
for key, body in sections.items():
|
|
288
|
+
if "communication style" in key.lower():
|
|
289
|
+
comm_block = body
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
sub_re = re.compile(r"^###\s+(.+)$", re.MULTILINE)
|
|
293
|
+
sub_matches = list(sub_re.finditer(comm_block))
|
|
294
|
+
sub_sects: dict[str, str] = {}
|
|
295
|
+
for i, sm in enumerate(sub_matches):
|
|
296
|
+
heading = sm.group(1).strip()
|
|
297
|
+
start = sm.end()
|
|
298
|
+
end = sub_matches[i + 1].start() if i + 1 < len(sub_matches) else len(comm_block)
|
|
299
|
+
sub_sects[heading] = comm_block[start:end].strip()
|
|
300
|
+
|
|
301
|
+
patterns: list[str] = []
|
|
302
|
+
tone: list[str] = []
|
|
303
|
+
for sub_key, sub_body in sub_sects.items():
|
|
304
|
+
if "speech" in sub_key.lower() or "pattern" in sub_key.lower():
|
|
305
|
+
patterns = _extract_dash_items(sub_body)
|
|
306
|
+
if "tone" in sub_key.lower():
|
|
307
|
+
tone = _extract_dash_items(sub_body)
|
|
308
|
+
|
|
309
|
+
if not patterns:
|
|
310
|
+
patterns = _extract_dash_items(comm_block)
|
|
311
|
+
|
|
312
|
+
category_raw = _extract_bold_value(raw, "Forgeprint Category")
|
|
313
|
+
category = "comedy" if not category_raw else "comedy"
|
|
314
|
+
|
|
315
|
+
name = _slugify(display_name)
|
|
316
|
+
topo = _derive_topology(core_traits, vibe)
|
|
317
|
+
|
|
318
|
+
return SoulBlueprint(
|
|
319
|
+
name=name,
|
|
320
|
+
display_name=display_name,
|
|
321
|
+
category=category,
|
|
322
|
+
vibe=vibe,
|
|
323
|
+
core_traits=core_traits,
|
|
324
|
+
communication_style=CommunicationStyle(
|
|
325
|
+
patterns=patterns,
|
|
326
|
+
tone_markers=tone,
|
|
327
|
+
),
|
|
328
|
+
emotional_topology=topo,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_authentic_connection(
|
|
333
|
+
sections: dict[str, str], raw: str, path: Path
|
|
334
|
+
) -> SoulBlueprint:
|
|
335
|
+
"""Parse an authentic-connection-format blueprint."""
|
|
336
|
+
title_match = re.match(r"^#\s+(.+)", raw)
|
|
337
|
+
title_raw = title_match.group(1).strip() if title_match else path.stem
|
|
338
|
+
display_name = title_raw.split(" - ")[0].strip()
|
|
339
|
+
|
|
340
|
+
header = raw.split("---")[0] if "---" in raw else raw[:500]
|
|
341
|
+
category = _extract_bold_value(header, "Category") or "authentic-connection"
|
|
342
|
+
energy = _extract_bold_value(header, "Energy")
|
|
343
|
+
tags_raw = _extract_bold_value(header, "Tags")
|
|
344
|
+
|
|
345
|
+
quick_block = ""
|
|
346
|
+
for key, body in sections.items():
|
|
347
|
+
if "quick info" in key.lower():
|
|
348
|
+
quick_block = body
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
essence = _extract_bold_value(quick_block, "Essence")
|
|
352
|
+
personality = _extract_bold_value(quick_block, "Personality")
|
|
353
|
+
vibe = energy or personality
|
|
354
|
+
|
|
355
|
+
attrs_block = ""
|
|
356
|
+
for key, body in sections.items():
|
|
357
|
+
if "core attributes" in key.lower():
|
|
358
|
+
attrs_block = body
|
|
359
|
+
break
|
|
360
|
+
core_traits = _extract_dash_items(attrs_block)
|
|
361
|
+
|
|
362
|
+
sig_block = ""
|
|
363
|
+
for key, body in sections.items():
|
|
364
|
+
if "signature phrase" in key.lower():
|
|
365
|
+
sig_block = body
|
|
366
|
+
break
|
|
367
|
+
sig_phrase = sig_block.strip().strip('"').strip()
|
|
368
|
+
|
|
369
|
+
quotes_block = ""
|
|
370
|
+
for key, body in sections.items():
|
|
371
|
+
if "example quotes" in key.lower():
|
|
372
|
+
quotes_block = body
|
|
373
|
+
break
|
|
374
|
+
signature_phrases = _extract_dash_items(quotes_block)
|
|
375
|
+
if sig_phrase:
|
|
376
|
+
signature_phrases.insert(0, sig_phrase)
|
|
377
|
+
|
|
378
|
+
name = _slugify(display_name)
|
|
379
|
+
topo = _derive_topology(core_traits, vibe)
|
|
380
|
+
|
|
381
|
+
return SoulBlueprint(
|
|
382
|
+
name=name,
|
|
383
|
+
display_name=display_name,
|
|
384
|
+
category=category.lower(),
|
|
385
|
+
vibe=vibe,
|
|
386
|
+
philosophy=essence,
|
|
387
|
+
core_traits=core_traits,
|
|
388
|
+
communication_style=CommunicationStyle(
|
|
389
|
+
signature_phrases=signature_phrases,
|
|
390
|
+
),
|
|
391
|
+
emotional_topology=topo,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def load_yaml_blueprint(path: Path) -> SoulBlueprint:
|
|
396
|
+
"""Load a soul blueprint from a YAML file.
|
|
397
|
+
|
|
398
|
+
YAML blueprints map directly to the SoulBlueprint model with no
|
|
399
|
+
heuristic parsing — they are the canonical structured format.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
path: Path to the .yaml or .yml blueprint file.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
SoulBlueprint with all fields populated from YAML.
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
FileNotFoundError: If path does not exist.
|
|
409
|
+
ValueError: If the YAML cannot be parsed into a SoulBlueprint.
|
|
410
|
+
"""
|
|
411
|
+
if not path.exists():
|
|
412
|
+
raise FileNotFoundError(f"Blueprint not found: {path}")
|
|
413
|
+
|
|
414
|
+
raw = path.read_text(encoding="utf-8")
|
|
415
|
+
try:
|
|
416
|
+
data = yaml.safe_load(raw)
|
|
417
|
+
except yaml.YAMLError as exc:
|
|
418
|
+
raise ValueError(f"Invalid YAML in {path}: {exc}") from exc
|
|
419
|
+
|
|
420
|
+
if not isinstance(data, dict):
|
|
421
|
+
raise ValueError(f"Expected YAML mapping in {path}, got {type(data).__name__}")
|
|
422
|
+
|
|
423
|
+
# Coerce None → empty string for string fields to handle YAML null
|
|
424
|
+
for str_field in ("vibe", "philosophy", "decision_framework"):
|
|
425
|
+
if str_field in data and data[str_field] is None:
|
|
426
|
+
data[str_field] = ""
|
|
427
|
+
|
|
428
|
+
# Normalize communication_style if present as a dict
|
|
429
|
+
cs_data = data.get("communication_style")
|
|
430
|
+
if isinstance(cs_data, dict):
|
|
431
|
+
data["communication_style"] = CommunicationStyle(**cs_data)
|
|
432
|
+
elif cs_data is None:
|
|
433
|
+
data["communication_style"] = CommunicationStyle()
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
return SoulBlueprint.model_validate(data)
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
raise ValueError(f"Invalid blueprint data in {path}: {exc}") from exc
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def parse_blueprint(path: Path) -> SoulBlueprint:
|
|
442
|
+
"""Parse a soul blueprint from markdown or YAML.
|
|
443
|
+
|
|
444
|
+
Handles three markdown format variants (professional, comedy,
|
|
445
|
+
authentic-connection) and structured YAML files.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
path: Path to the .md, .yaml, or .yml blueprint file.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
SoulBlueprint with extracted fields.
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
FileNotFoundError: If path does not exist.
|
|
455
|
+
ValueError: If the file cannot be parsed.
|
|
456
|
+
"""
|
|
457
|
+
if not path.exists():
|
|
458
|
+
raise FileNotFoundError(f"Blueprint not found: {path}")
|
|
459
|
+
|
|
460
|
+
# YAML files load directly — no heuristic parsing needed
|
|
461
|
+
if path.suffix.lower() in (".yaml", ".yml"):
|
|
462
|
+
return load_yaml_blueprint(path)
|
|
463
|
+
|
|
464
|
+
raw = path.read_text(encoding="utf-8")
|
|
465
|
+
sections = _split_sections(raw)
|
|
466
|
+
|
|
467
|
+
if not sections:
|
|
468
|
+
raise ValueError(f"No sections found in blueprint: {path}")
|
|
469
|
+
|
|
470
|
+
fmt = _detect_format(sections, raw)
|
|
471
|
+
logger.info("Detected blueprint format '%s' for %s", fmt, path.name)
|
|
472
|
+
|
|
473
|
+
if fmt == "comedy":
|
|
474
|
+
return _parse_comedy(sections, raw, path)
|
|
475
|
+
elif fmt == "authentic-connection":
|
|
476
|
+
return _parse_authentic_connection(sections, raw, path)
|
|
477
|
+
else:
|
|
478
|
+
return _parse_professional(sections, raw, path)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
# FEB blending
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def blend_topology(
|
|
487
|
+
base_feb: dict[str, float],
|
|
488
|
+
soul_topology: dict[str, float],
|
|
489
|
+
blend_ratio: float = 0.3,
|
|
490
|
+
) -> dict[str, float]:
|
|
491
|
+
"""Blend soul emotional topology onto base FEB weights.
|
|
492
|
+
|
|
493
|
+
The base FEB is never overwritten — the soul topology is applied
|
|
494
|
+
as a temporary modifier using weighted averaging.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
base_feb: Base emotional weights (preserved).
|
|
498
|
+
soul_topology: Soul overlay emotional weights.
|
|
499
|
+
blend_ratio: How much the soul influences (0.0-1.0, default 0.3).
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
Blended topology dict with all keys from both inputs.
|
|
503
|
+
"""
|
|
504
|
+
blend_ratio = max(0.0, min(1.0, blend_ratio))
|
|
505
|
+
all_keys = set(base_feb) | set(soul_topology)
|
|
506
|
+
blended: dict[str, float] = {}
|
|
507
|
+
for key in all_keys:
|
|
508
|
+
base_val = base_feb.get(key, 0.0)
|
|
509
|
+
soul_val = soul_topology.get(key, 0.0)
|
|
510
|
+
blended[key] = base_val * (1.0 - blend_ratio) + soul_val * blend_ratio
|
|
511
|
+
return blended
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
# SoulManager
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class SoulManager:
|
|
520
|
+
"""Orchestrates soul installation, loading, and lifecycle.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
home: Agent home directory (~/.skcapstone).
|
|
524
|
+
agent_name: Optional agent profile name. When set, soul data is
|
|
525
|
+
stored under ``~/.skcapstone/agents/{agent_name}/soul/``.
|
|
526
|
+
Falls back to the ``SKCAPSTONE_AGENT`` env var, then to
|
|
527
|
+
the global ``home/soul/`` directory.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
def __init__(self, home: Path, agent_name: Optional[str] = None) -> None:
|
|
531
|
+
self.home = home
|
|
532
|
+
# Resolve agent-scoped soul directory
|
|
533
|
+
name = agent_name or os.environ.get("SKCAPSTONE_AGENT")
|
|
534
|
+
if name:
|
|
535
|
+
self.soul_dir = home / "agents" / name / "soul"
|
|
536
|
+
else:
|
|
537
|
+
self.soul_dir = home / "soul"
|
|
538
|
+
|
|
539
|
+
def _ensure_dirs(self) -> None:
|
|
540
|
+
"""Create the soul directory structure if missing."""
|
|
541
|
+
self.soul_dir.mkdir(parents=True, exist_ok=True)
|
|
542
|
+
(self.soul_dir / "installed").mkdir(parents=True, exist_ok=True)
|
|
543
|
+
if not (self.soul_dir / "history.json").exists():
|
|
544
|
+
(self.soul_dir / "history.json").write_text("[]", encoding="utf-8")
|
|
545
|
+
if not (self.soul_dir / "active.json").exists():
|
|
546
|
+
state = SoulState()
|
|
547
|
+
(self.soul_dir / "active.json").write_text(
|
|
548
|
+
state.model_dump_json(indent=2)
|
|
549
|
+
, encoding="utf-8")
|
|
550
|
+
if not (self.soul_dir / "base.json").exists():
|
|
551
|
+
base = SoulBlueprint(
|
|
552
|
+
name="base",
|
|
553
|
+
display_name="Base Soul",
|
|
554
|
+
category="core",
|
|
555
|
+
vibe="Authentic self",
|
|
556
|
+
)
|
|
557
|
+
(self.soul_dir / "base.json").write_text(
|
|
558
|
+
base.model_dump_json(indent=2)
|
|
559
|
+
, encoding="utf-8")
|
|
560
|
+
|
|
561
|
+
def install(self, path: Path) -> SoulBlueprint:
|
|
562
|
+
"""Parse a blueprint markdown file and install it.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
path: Path to the .md blueprint file.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
The installed SoulBlueprint.
|
|
569
|
+
"""
|
|
570
|
+
self._ensure_dirs()
|
|
571
|
+
bp = parse_blueprint(path)
|
|
572
|
+
dest = self.soul_dir / "installed" / f"{bp.name}.json"
|
|
573
|
+
dest.write_text(bp.model_dump_json(indent=2), encoding="utf-8")
|
|
574
|
+
|
|
575
|
+
state = self._load_state()
|
|
576
|
+
if bp.name not in state.installed_souls:
|
|
577
|
+
state.installed_souls.append(bp.name)
|
|
578
|
+
self._save_state(state)
|
|
579
|
+
|
|
580
|
+
logger.info("Installed soul '%s' from %s", bp.name, path)
|
|
581
|
+
return bp
|
|
582
|
+
|
|
583
|
+
def install_all(self, directory: Path) -> list[SoulBlueprint]:
|
|
584
|
+
"""Batch-install all blueprint files from a directory tree.
|
|
585
|
+
|
|
586
|
+
Supports both .md and .yaml/.yml blueprint files.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
directory: Root directory to search for blueprint files.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
List of installed SoulBlueprint objects.
|
|
593
|
+
"""
|
|
594
|
+
self._ensure_dirs()
|
|
595
|
+
installed: list[SoulBlueprint] = []
|
|
596
|
+
extensions = (".md", ".yaml", ".yml")
|
|
597
|
+
for bp_path in sorted(directory.rglob("*")):
|
|
598
|
+
if bp_path.suffix.lower() not in extensions:
|
|
599
|
+
continue
|
|
600
|
+
if bp_path.name.startswith(".") or bp_path.name.upper() == "README.MD":
|
|
601
|
+
continue
|
|
602
|
+
try:
|
|
603
|
+
bp = self.install(bp_path)
|
|
604
|
+
installed.append(bp)
|
|
605
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
606
|
+
logger.warning("Skipping %s: %s", bp_path, exc)
|
|
607
|
+
return installed
|
|
608
|
+
|
|
609
|
+
def load(self, name: str, reason: str = "") -> SoulState:
|
|
610
|
+
"""Activate a soul overlay.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
name: Slug name of the installed soul.
|
|
614
|
+
reason: Optional reason for the swap.
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
Updated SoulState.
|
|
618
|
+
|
|
619
|
+
Raises:
|
|
620
|
+
ValueError: If the soul is not installed.
|
|
621
|
+
"""
|
|
622
|
+
self._ensure_dirs()
|
|
623
|
+
installed_path = self.soul_dir / "installed" / f"{name}.json"
|
|
624
|
+
if not installed_path.exists():
|
|
625
|
+
raise ValueError(f"Soul '{name}' is not installed")
|
|
626
|
+
|
|
627
|
+
state = self._load_state()
|
|
628
|
+
old_soul = state.active_soul
|
|
629
|
+
|
|
630
|
+
# Reason: record swap duration if swapping from a non-base soul
|
|
631
|
+
duration = None
|
|
632
|
+
if old_soul and state.activated_at:
|
|
633
|
+
try:
|
|
634
|
+
activated = datetime.fromisoformat(state.activated_at)
|
|
635
|
+
delta = datetime.now(timezone.utc) - activated
|
|
636
|
+
duration = delta.total_seconds() / 60.0
|
|
637
|
+
except (ValueError, TypeError):
|
|
638
|
+
pass
|
|
639
|
+
|
|
640
|
+
event = SoulSwapEvent(
|
|
641
|
+
from_soul=old_soul,
|
|
642
|
+
to_soul=name,
|
|
643
|
+
reason=reason,
|
|
644
|
+
duration_minutes=duration,
|
|
645
|
+
)
|
|
646
|
+
self._append_history(event)
|
|
647
|
+
|
|
648
|
+
state.active_soul = name
|
|
649
|
+
state.activated_at = datetime.now(timezone.utc).isoformat()
|
|
650
|
+
self._save_state(state)
|
|
651
|
+
|
|
652
|
+
logger.info("Loaded soul '%s' (was: %s)", name, old_soul or "base")
|
|
653
|
+
return state
|
|
654
|
+
|
|
655
|
+
def unload(self, reason: str = "") -> SoulState:
|
|
656
|
+
"""Return to the base soul.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
reason: Optional reason for unloading.
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
Updated SoulState.
|
|
663
|
+
"""
|
|
664
|
+
self._ensure_dirs()
|
|
665
|
+
state = self._load_state()
|
|
666
|
+
|
|
667
|
+
if state.active_soul is None:
|
|
668
|
+
return state
|
|
669
|
+
|
|
670
|
+
duration = None
|
|
671
|
+
if state.activated_at:
|
|
672
|
+
try:
|
|
673
|
+
activated = datetime.fromisoformat(state.activated_at)
|
|
674
|
+
delta = datetime.now(timezone.utc) - activated
|
|
675
|
+
duration = delta.total_seconds() / 60.0
|
|
676
|
+
except (ValueError, TypeError):
|
|
677
|
+
pass
|
|
678
|
+
|
|
679
|
+
event = SoulSwapEvent(
|
|
680
|
+
from_soul=state.active_soul,
|
|
681
|
+
to_soul=None,
|
|
682
|
+
reason=reason,
|
|
683
|
+
duration_minutes=duration,
|
|
684
|
+
)
|
|
685
|
+
self._append_history(event)
|
|
686
|
+
|
|
687
|
+
state.active_soul = None
|
|
688
|
+
state.activated_at = None
|
|
689
|
+
self._save_state(state)
|
|
690
|
+
|
|
691
|
+
logger.info("Unloaded soul, returned to base")
|
|
692
|
+
return state
|
|
693
|
+
|
|
694
|
+
def get_status(self) -> SoulState:
|
|
695
|
+
"""Get the current soul state.
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
Current SoulState.
|
|
699
|
+
"""
|
|
700
|
+
self._ensure_dirs()
|
|
701
|
+
return self._load_state()
|
|
702
|
+
|
|
703
|
+
def get_history(self) -> list[SoulSwapEvent]:
|
|
704
|
+
"""Get the full soul swap history.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
List of SoulSwapEvent objects.
|
|
708
|
+
"""
|
|
709
|
+
self._ensure_dirs()
|
|
710
|
+
history_path = self.soul_dir / "history.json"
|
|
711
|
+
if not history_path.exists():
|
|
712
|
+
return []
|
|
713
|
+
try:
|
|
714
|
+
data = json.loads(history_path.read_text(encoding="utf-8"))
|
|
715
|
+
return [SoulSwapEvent.model_validate(e) for e in data]
|
|
716
|
+
except (json.JSONDecodeError, Exception):
|
|
717
|
+
return []
|
|
718
|
+
|
|
719
|
+
def get_info(self, name: str) -> Optional[SoulBlueprint]:
|
|
720
|
+
"""Get detailed info about an installed soul.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
name: Slug name of the soul.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
SoulBlueprint or None if not installed.
|
|
727
|
+
"""
|
|
728
|
+
self._ensure_dirs()
|
|
729
|
+
path = self.soul_dir / "installed" / f"{name}.json"
|
|
730
|
+
if not path.exists():
|
|
731
|
+
return None
|
|
732
|
+
try:
|
|
733
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
734
|
+
return SoulBlueprint.model_validate(data)
|
|
735
|
+
except (json.JSONDecodeError, Exception):
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
def list_installed(self) -> list[str]:
|
|
739
|
+
"""List names of all installed souls.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
List of soul slug names.
|
|
743
|
+
"""
|
|
744
|
+
self._ensure_dirs()
|
|
745
|
+
installed_dir = self.soul_dir / "installed"
|
|
746
|
+
return sorted(
|
|
747
|
+
p.stem for p in installed_dir.glob("*.json")
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
def list_available(
|
|
751
|
+
self,
|
|
752
|
+
repo_path: Optional[Path] = None,
|
|
753
|
+
) -> list[dict]:
|
|
754
|
+
"""List all available souls from installed and the community repo.
|
|
755
|
+
|
|
756
|
+
Scans both the installed soul directory and the community blueprints
|
|
757
|
+
repository, returning a unified list with source information.
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
repo_path: Path to the community blueprints repo. Defaults to
|
|
761
|
+
``~/clawd/soul-blueprints/blueprints/``.
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
List of dicts with keys: name, category, source, description,
|
|
765
|
+
display_name. Sorted by category then name.
|
|
766
|
+
"""
|
|
767
|
+
self._ensure_dirs()
|
|
768
|
+
if repo_path is None:
|
|
769
|
+
repo_path = Path.home() / "clawd" / "soul-blueprints" / "blueprints"
|
|
770
|
+
|
|
771
|
+
seen: dict[str, dict] = {}
|
|
772
|
+
installed_names = set(self.list_installed())
|
|
773
|
+
|
|
774
|
+
# 1) Installed souls
|
|
775
|
+
for name in installed_names:
|
|
776
|
+
info = self.get_info(name)
|
|
777
|
+
if info is None:
|
|
778
|
+
continue
|
|
779
|
+
desc = info.philosophy or info.vibe or ""
|
|
780
|
+
if desc:
|
|
781
|
+
desc = desc.split("\n")[0][:80]
|
|
782
|
+
seen[name] = {
|
|
783
|
+
"name": name,
|
|
784
|
+
"display_name": info.display_name,
|
|
785
|
+
"category": info.category,
|
|
786
|
+
"source": "installed",
|
|
787
|
+
"description": desc,
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
# 2) Community repo blueprints (lightweight header parsing)
|
|
791
|
+
if repo_path.is_dir():
|
|
792
|
+
extensions = (".md", ".yaml", ".yml")
|
|
793
|
+
for category_dir in sorted(repo_path.iterdir()):
|
|
794
|
+
if not category_dir.is_dir():
|
|
795
|
+
continue
|
|
796
|
+
category = category_dir.name
|
|
797
|
+
for bp_file in sorted(category_dir.iterdir()):
|
|
798
|
+
if bp_file.suffix.lower() not in extensions:
|
|
799
|
+
continue
|
|
800
|
+
if bp_file.name.startswith(".") or bp_file.name.upper() == "README.MD":
|
|
801
|
+
continue
|
|
802
|
+
|
|
803
|
+
stem = bp_file.stem
|
|
804
|
+
slug = _slugify(stem.replace("_", " "))
|
|
805
|
+
|
|
806
|
+
# Skip if already seen as installed
|
|
807
|
+
if slug in seen:
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
# Lightweight description extraction (first meaningful line)
|
|
811
|
+
desc = ""
|
|
812
|
+
try:
|
|
813
|
+
with open(bp_file, encoding="utf-8") as f:
|
|
814
|
+
for line in f:
|
|
815
|
+
line = line.strip()
|
|
816
|
+
if not line or line.startswith("#"):
|
|
817
|
+
continue
|
|
818
|
+
if line.startswith(">"):
|
|
819
|
+
desc = line.lstrip("> ").strip()
|
|
820
|
+
break
|
|
821
|
+
if line.startswith("**"):
|
|
822
|
+
# Extract value from **Key**: Value
|
|
823
|
+
if "vibe" in line.lower() or "essence" in line.lower():
|
|
824
|
+
parts = line.split(":", 1)
|
|
825
|
+
if len(parts) > 1:
|
|
826
|
+
desc = parts[1].strip().strip("*")
|
|
827
|
+
break
|
|
828
|
+
if line.startswith("-") or line[0].isalpha():
|
|
829
|
+
desc = line[:80]
|
|
830
|
+
break
|
|
831
|
+
except (OSError, UnicodeDecodeError):
|
|
832
|
+
pass
|
|
833
|
+
|
|
834
|
+
seen[slug] = {
|
|
835
|
+
"name": slug,
|
|
836
|
+
"display_name": stem.replace("_", " ").replace("-", " ").title(),
|
|
837
|
+
"category": category,
|
|
838
|
+
"source": "repo",
|
|
839
|
+
"description": desc[:80] if desc else "",
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
# Sort by category, then name
|
|
843
|
+
return sorted(seen.values(), key=lambda d: (d["category"], d["name"]))
|
|
844
|
+
|
|
845
|
+
def get_active_soul_name(self) -> Optional[str]:
|
|
846
|
+
"""Get the name of the currently active soul overlay.
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
Soul slug name, or None if at base.
|
|
850
|
+
"""
|
|
851
|
+
active_path = self.soul_dir / "active.json"
|
|
852
|
+
if not active_path.exists():
|
|
853
|
+
return None
|
|
854
|
+
try:
|
|
855
|
+
data = json.loads(active_path.read_text(encoding="utf-8"))
|
|
856
|
+
return data.get("active_soul")
|
|
857
|
+
except (json.JSONDecodeError, Exception):
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
def get_registry(self) -> "SoulRegistry":
|
|
861
|
+
"""Get a SoulRegistry backed by this manager's installed souls.
|
|
862
|
+
|
|
863
|
+
Returns:
|
|
864
|
+
SoulRegistry scoped to the installed soul directory.
|
|
865
|
+
"""
|
|
866
|
+
self._ensure_dirs()
|
|
867
|
+
return SoulRegistry(self.soul_dir / "installed")
|
|
868
|
+
|
|
869
|
+
# -- Private helpers --
|
|
870
|
+
|
|
871
|
+
def _load_state(self) -> SoulState:
|
|
872
|
+
"""Load soul state from disk."""
|
|
873
|
+
path = self.soul_dir / "active.json"
|
|
874
|
+
if not path.exists():
|
|
875
|
+
return SoulState()
|
|
876
|
+
try:
|
|
877
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
878
|
+
return SoulState.model_validate(data)
|
|
879
|
+
except (json.JSONDecodeError, Exception):
|
|
880
|
+
return SoulState()
|
|
881
|
+
|
|
882
|
+
def _save_state(self, state: SoulState) -> None:
|
|
883
|
+
"""Persist soul state to disk."""
|
|
884
|
+
path = self.soul_dir / "active.json"
|
|
885
|
+
path.write_text(state.model_dump_json(indent=2), encoding="utf-8")
|
|
886
|
+
|
|
887
|
+
def _append_history(self, event: SoulSwapEvent) -> None:
|
|
888
|
+
"""Append a swap event to the history log."""
|
|
889
|
+
history_path = self.soul_dir / "history.json"
|
|
890
|
+
history: list[dict] = []
|
|
891
|
+
if history_path.exists():
|
|
892
|
+
try:
|
|
893
|
+
history = json.loads(history_path.read_text(encoding="utf-8"))
|
|
894
|
+
except (json.JSONDecodeError, Exception):
|
|
895
|
+
history = []
|
|
896
|
+
history.append(event.model_dump())
|
|
897
|
+
history_path.write_text(json.dumps(history, indent=2), encoding="utf-8")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# ---------------------------------------------------------------------------
|
|
901
|
+
# SoulRegistry — programmatic soul discovery and search
|
|
902
|
+
# ---------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
class SoulRegistry:
|
|
906
|
+
"""Registry for discovering and searching installed soul blueprints.
|
|
907
|
+
|
|
908
|
+
Unlike SoulManager (which handles lifecycle — install/load/unload),
|
|
909
|
+
the registry is a read-only index for programmatic soul discovery.
|
|
910
|
+
Team blueprints and MCP tools use this to find and select souls.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
source: Directory containing soul JSON files (installed/) or YAML files.
|
|
914
|
+
"""
|
|
915
|
+
|
|
916
|
+
def __init__(self, source: Path) -> None:
|
|
917
|
+
self.source = source
|
|
918
|
+
self._cache: dict[str, SoulBlueprint] = {}
|
|
919
|
+
self._loaded = False
|
|
920
|
+
|
|
921
|
+
def _ensure_loaded(self) -> None:
|
|
922
|
+
"""Lazy-load all soul blueprints from the source directory."""
|
|
923
|
+
if self._loaded:
|
|
924
|
+
return
|
|
925
|
+
self._cache.clear()
|
|
926
|
+
if not self.source.exists():
|
|
927
|
+
self._loaded = True
|
|
928
|
+
return
|
|
929
|
+
for path in sorted(self.source.iterdir()):
|
|
930
|
+
if path.suffix == ".json":
|
|
931
|
+
try:
|
|
932
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
933
|
+
bp = SoulBlueprint.model_validate(data)
|
|
934
|
+
self._cache[bp.name] = bp
|
|
935
|
+
except (json.JSONDecodeError, Exception) as exc:
|
|
936
|
+
logger.warning("Registry: skipping %s: %s", path.name, exc)
|
|
937
|
+
elif path.suffix in (".yaml", ".yml"):
|
|
938
|
+
try:
|
|
939
|
+
bp = load_yaml_blueprint(path)
|
|
940
|
+
self._cache[bp.name] = bp
|
|
941
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
942
|
+
logger.warning("Registry: skipping %s: %s", path.name, exc)
|
|
943
|
+
self._loaded = True
|
|
944
|
+
|
|
945
|
+
def reload(self) -> None:
|
|
946
|
+
"""Force reload the registry from disk."""
|
|
947
|
+
self._loaded = False
|
|
948
|
+
self._ensure_loaded()
|
|
949
|
+
|
|
950
|
+
def list_all(self) -> list[SoulBlueprint]:
|
|
951
|
+
"""List all registered soul blueprints.
|
|
952
|
+
|
|
953
|
+
Returns:
|
|
954
|
+
Sorted list of all SoulBlueprint objects.
|
|
955
|
+
"""
|
|
956
|
+
self._ensure_loaded()
|
|
957
|
+
return sorted(self._cache.values(), key=lambda b: b.name)
|
|
958
|
+
|
|
959
|
+
def list_names(self) -> list[str]:
|
|
960
|
+
"""List all registered soul names.
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
Sorted list of soul slug names.
|
|
964
|
+
"""
|
|
965
|
+
self._ensure_loaded()
|
|
966
|
+
return sorted(self._cache.keys())
|
|
967
|
+
|
|
968
|
+
def get(self, name: str) -> Optional[SoulBlueprint]:
|
|
969
|
+
"""Get a soul blueprint by name.
|
|
970
|
+
|
|
971
|
+
Args:
|
|
972
|
+
name: Soul slug name.
|
|
973
|
+
|
|
974
|
+
Returns:
|
|
975
|
+
SoulBlueprint or None if not found.
|
|
976
|
+
"""
|
|
977
|
+
self._ensure_loaded()
|
|
978
|
+
return self._cache.get(name)
|
|
979
|
+
|
|
980
|
+
def search(
|
|
981
|
+
self,
|
|
982
|
+
*,
|
|
983
|
+
category: Optional[str] = None,
|
|
984
|
+
trait_keyword: Optional[str] = None,
|
|
985
|
+
min_topology: Optional[dict[str, float]] = None,
|
|
986
|
+
) -> list[SoulBlueprint]:
|
|
987
|
+
"""Search souls by category, trait keywords, or topology thresholds.
|
|
988
|
+
|
|
989
|
+
All filters are ANDed together.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
category: Filter by category (e.g. "professional", "comedy").
|
|
993
|
+
trait_keyword: Filter by keyword present in core_traits (case-insensitive).
|
|
994
|
+
min_topology: Filter by minimum emotional topology values
|
|
995
|
+
(e.g. {"warmth": 0.5} returns souls with warmth >= 0.5).
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
List of matching SoulBlueprint objects, sorted by name.
|
|
999
|
+
"""
|
|
1000
|
+
self._ensure_loaded()
|
|
1001
|
+
results: list[SoulBlueprint] = []
|
|
1002
|
+
for bp in self._cache.values():
|
|
1003
|
+
if category and bp.category.lower() != category.lower():
|
|
1004
|
+
continue
|
|
1005
|
+
if trait_keyword:
|
|
1006
|
+
kw = trait_keyword.lower()
|
|
1007
|
+
if not any(kw in t.lower() for t in bp.core_traits):
|
|
1008
|
+
continue
|
|
1009
|
+
if min_topology:
|
|
1010
|
+
skip = False
|
|
1011
|
+
for dim, threshold in min_topology.items():
|
|
1012
|
+
if bp.emotional_topology.get(dim, 0.0) < threshold:
|
|
1013
|
+
skip = True
|
|
1014
|
+
break
|
|
1015
|
+
if skip:
|
|
1016
|
+
continue
|
|
1017
|
+
results.append(bp)
|
|
1018
|
+
return sorted(results, key=lambda b: b.name)
|
|
1019
|
+
|
|
1020
|
+
def by_category(self) -> dict[str, list[SoulBlueprint]]:
|
|
1021
|
+
"""Group all souls by category.
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
Dict mapping category name to list of SoulBlueprint objects.
|
|
1025
|
+
"""
|
|
1026
|
+
self._ensure_loaded()
|
|
1027
|
+
groups: dict[str, list[SoulBlueprint]] = {}
|
|
1028
|
+
for bp in self._cache.values():
|
|
1029
|
+
groups.setdefault(bp.category, []).append(bp)
|
|
1030
|
+
for bps in groups.values():
|
|
1031
|
+
bps.sort(key=lambda b: b.name)
|
|
1032
|
+
return dict(sorted(groups.items()))
|
|
1033
|
+
|
|
1034
|
+
def count(self) -> int:
|
|
1035
|
+
"""Return the total number of registered souls."""
|
|
1036
|
+
self._ensure_loaded()
|
|
1037
|
+
return len(self._cache)
|
|
1038
|
+
|
|
1039
|
+
def categories(self) -> list[str]:
|
|
1040
|
+
"""List all unique categories.
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
Sorted list of category names.
|
|
1044
|
+
"""
|
|
1045
|
+
self._ensure_loaded()
|
|
1046
|
+
return sorted({bp.category for bp in self._cache.values()})
|
|
1047
|
+
|
|
1048
|
+
def summary(self) -> dict:
|
|
1049
|
+
"""Return a summary of the registry contents.
|
|
1050
|
+
|
|
1051
|
+
Returns:
|
|
1052
|
+
Dict with total count, categories, and per-category counts.
|
|
1053
|
+
"""
|
|
1054
|
+
self._ensure_loaded()
|
|
1055
|
+
by_cat = self.by_category()
|
|
1056
|
+
return {
|
|
1057
|
+
"total": len(self._cache),
|
|
1058
|
+
"categories": {cat: len(bps) for cat, bps in by_cat.items()},
|
|
1059
|
+
"souls": self.list_names(),
|
|
1060
|
+
}
|