@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,1156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FUSE Mount — Sovereign Virtual Filesystem.
|
|
3
|
+
|
|
4
|
+
Exposes the sovereign agent's data (memories, identity, inbox, outbox,
|
|
5
|
+
coordination tasks) as a mountable POSIX filesystem via FUSE.
|
|
6
|
+
|
|
7
|
+
Virtual directory layout::
|
|
8
|
+
|
|
9
|
+
/
|
|
10
|
+
├── memories/
|
|
11
|
+
│ ├── short/ — short-term memory files (.md)
|
|
12
|
+
│ ├── mid/ — mid-term memory files (.md)
|
|
13
|
+
│ └── long/ — long-term memory files (.md)
|
|
14
|
+
├── documents/ — SKSeal signed documents
|
|
15
|
+
├── identity/
|
|
16
|
+
│ ├── card.json — CapAuth identity card
|
|
17
|
+
│ └── fingerprint.txt — PGP fingerprint
|
|
18
|
+
├── inbox/ — SKComm incoming messages (read-only)
|
|
19
|
+
├── outbox/ — Write here to send via SKComm
|
|
20
|
+
└── coordination/ — Task board files (.json)
|
|
21
|
+
|
|
22
|
+
Writing to ``/outbox/<agent_name>.msg`` enqueues a message via SKComm.
|
|
23
|
+
|
|
24
|
+
Dependencies (optional):
|
|
25
|
+
pip install skcapstone[fuse] # pulls in fusepy
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import errno
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import stat
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger("skcapstone.fuse")
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Layer name mapping: virtual dir slug → MemoryLayer value
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
_LAYER_SLUG_TO_VALUE: Dict[str, str] = {
|
|
49
|
+
"short": "short-term",
|
|
50
|
+
"mid": "mid-term",
|
|
51
|
+
"long": "long-term",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_LAYER_VALUE_TO_SLUG: Dict[str, str] = {v: k for k, v in _LAYER_SLUG_TO_VALUE.items()}
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Virtual filesystem path constants
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
_MEMORIES_DIR = "memories"
|
|
61
|
+
_DOCUMENTS_DIR = "documents"
|
|
62
|
+
_IDENTITY_DIR = "identity"
|
|
63
|
+
_INBOX_DIR = "inbox"
|
|
64
|
+
_OUTBOX_DIR = "outbox"
|
|
65
|
+
_COORDINATION_DIR = "coordination"
|
|
66
|
+
|
|
67
|
+
_TOP_LEVEL_DIRS = [
|
|
68
|
+
_MEMORIES_DIR,
|
|
69
|
+
_DOCUMENTS_DIR,
|
|
70
|
+
_IDENTITY_DIR,
|
|
71
|
+
_INBOX_DIR,
|
|
72
|
+
_OUTBOX_DIR,
|
|
73
|
+
_COORDINATION_DIR,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
_MEMORY_SUBDIRS = list(_LAYER_SLUG_TO_VALUE.keys()) # short, mid, long
|
|
77
|
+
|
|
78
|
+
_IDENTITY_FILES = ["card.json", "fingerprint.txt"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Helpers
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _now_ts() -> float:
|
|
87
|
+
"""Return the current Unix timestamp as a float.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Current UTC time as a float POSIX timestamp.
|
|
91
|
+
"""
|
|
92
|
+
return datetime.now(timezone.utc).timestamp()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dir_stat(nlink: int = 2) -> Dict[str, Any]:
|
|
96
|
+
"""Build a stat dict for a virtual directory.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
nlink: Number of hard links (default: 2).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Stat dictionary suitable for FUSE Operations.getattr().
|
|
103
|
+
"""
|
|
104
|
+
ts = _now_ts()
|
|
105
|
+
return {
|
|
106
|
+
"st_mode": stat.S_IFDIR | 0o555,
|
|
107
|
+
"st_nlink": nlink,
|
|
108
|
+
"st_uid": os.getuid(),
|
|
109
|
+
"st_gid": os.getgid(),
|
|
110
|
+
"st_size": 0,
|
|
111
|
+
"st_atime": ts,
|
|
112
|
+
"st_mtime": ts,
|
|
113
|
+
"st_ctime": ts,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _file_stat(size: int, writable: bool = False) -> Dict[str, Any]:
|
|
118
|
+
"""Build a stat dict for a virtual file.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
size: File size in bytes.
|
|
122
|
+
writable: Whether the file is writable (e.g., outbox files).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Stat dictionary suitable for FUSE Operations.getattr().
|
|
126
|
+
"""
|
|
127
|
+
ts = _now_ts()
|
|
128
|
+
mode = stat.S_IFREG | (0o644 if writable else 0o444)
|
|
129
|
+
return {
|
|
130
|
+
"st_mode": mode,
|
|
131
|
+
"st_nlink": 1,
|
|
132
|
+
"st_uid": os.getuid(),
|
|
133
|
+
"st_gid": os.getgid(),
|
|
134
|
+
"st_size": size,
|
|
135
|
+
"st_atime": ts,
|
|
136
|
+
"st_mtime": ts,
|
|
137
|
+
"st_ctime": ts,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Content generators
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _memory_to_markdown(memory: Dict[str, Any]) -> bytes:
|
|
147
|
+
"""Render a memory dict as a Markdown document.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
memory: Parsed JSON dict of a MemoryEntry.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
UTF-8 encoded Markdown bytes.
|
|
154
|
+
"""
|
|
155
|
+
lines: List[str] = []
|
|
156
|
+
lines.append(f"# Memory: {memory.get('memory_id', 'unknown')}")
|
|
157
|
+
lines.append("")
|
|
158
|
+
|
|
159
|
+
created = memory.get("created_at", "")
|
|
160
|
+
if created:
|
|
161
|
+
lines.append(f"**Created:** {created}")
|
|
162
|
+
|
|
163
|
+
layer = memory.get("layer", "")
|
|
164
|
+
if layer:
|
|
165
|
+
lines.append(f"**Layer:** {layer}")
|
|
166
|
+
|
|
167
|
+
importance = memory.get("importance")
|
|
168
|
+
if importance is not None:
|
|
169
|
+
lines.append(f"**Importance:** {importance:.2f}")
|
|
170
|
+
|
|
171
|
+
tags = memory.get("tags", [])
|
|
172
|
+
if tags:
|
|
173
|
+
lines.append(f"**Tags:** {', '.join(tags)}")
|
|
174
|
+
|
|
175
|
+
soul = memory.get("soul_context")
|
|
176
|
+
if soul:
|
|
177
|
+
lines.append(f"**Soul:** {soul}")
|
|
178
|
+
|
|
179
|
+
source = memory.get("source", "")
|
|
180
|
+
if source:
|
|
181
|
+
lines.append(f"**Source:** {source}")
|
|
182
|
+
|
|
183
|
+
lines.append("")
|
|
184
|
+
lines.append("## Content")
|
|
185
|
+
lines.append("")
|
|
186
|
+
lines.append(memory.get("content", ""))
|
|
187
|
+
|
|
188
|
+
metadata = memory.get("metadata") or {}
|
|
189
|
+
if metadata:
|
|
190
|
+
lines.append("")
|
|
191
|
+
lines.append("## Metadata")
|
|
192
|
+
lines.append("")
|
|
193
|
+
for k, v in metadata.items():
|
|
194
|
+
lines.append(f"- **{k}:** {v}")
|
|
195
|
+
|
|
196
|
+
return "\n".join(lines).encode("utf-8")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _load_memory_file(memory_dir: Path, layer_value: str, memory_id: str) -> Optional[bytes]:
|
|
200
|
+
"""Load a memory JSON file and render it as Markdown.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
memory_dir: Root memory directory (``~/.skcapstone/memory``).
|
|
204
|
+
layer_value: MemoryLayer value string (e.g. ``short-term``).
|
|
205
|
+
memory_id: Memory ID without extension.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
UTF-8 encoded Markdown bytes, or None if not found/invalid.
|
|
209
|
+
"""
|
|
210
|
+
path = memory_dir / layer_value / f"{memory_id}.json"
|
|
211
|
+
if not path.exists():
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
215
|
+
return _memory_to_markdown(data)
|
|
216
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
217
|
+
logger.warning("Failed to load memory %s: %s", path, exc)
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _list_memory_ids(memory_dir: Path, layer_value: str) -> List[str]:
|
|
222
|
+
"""List all memory IDs for a given layer.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
memory_dir: Root memory directory.
|
|
226
|
+
layer_value: MemoryLayer value string.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of memory IDs (without .json extension), sorted.
|
|
230
|
+
"""
|
|
231
|
+
layer_dir = memory_dir / layer_value
|
|
232
|
+
if not layer_dir.exists():
|
|
233
|
+
return []
|
|
234
|
+
return sorted(p.stem for p in layer_dir.glob("*.json"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _build_identity_card(agent_home: Path) -> bytes:
|
|
238
|
+
"""Build a JSON identity card from the CapAuth profile.
|
|
239
|
+
|
|
240
|
+
Falls back to manifest data if CapAuth is unavailable.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
agent_home: Agent home directory.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
UTF-8 encoded JSON bytes.
|
|
247
|
+
"""
|
|
248
|
+
# Try CapAuth profile
|
|
249
|
+
capauth_profile = Path("~/.capauth/profile.json").expanduser()
|
|
250
|
+
if capauth_profile.exists():
|
|
251
|
+
try:
|
|
252
|
+
data = json.loads(capauth_profile.read_text(encoding="utf-8"))
|
|
253
|
+
card: Dict[str, Any] = {
|
|
254
|
+
"name": data.get("name", "unknown"),
|
|
255
|
+
"email": data.get("email", ""),
|
|
256
|
+
"fingerprint": data.get("fingerprint", ""),
|
|
257
|
+
"created_at": data.get("created_at", ""),
|
|
258
|
+
"source": "capauth",
|
|
259
|
+
}
|
|
260
|
+
return json.dumps(card, indent=2).encode("utf-8")
|
|
261
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
262
|
+
logger.warning("Failed to read CapAuth profile for identity card: %s", exc)
|
|
263
|
+
|
|
264
|
+
# Fall back to manifest
|
|
265
|
+
manifest_path = agent_home / "manifest.json"
|
|
266
|
+
if manifest_path.exists():
|
|
267
|
+
try:
|
|
268
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
269
|
+
card = {
|
|
270
|
+
"name": data.get("name", "unknown"),
|
|
271
|
+
"fingerprint": data.get("identity", {}).get("fingerprint", ""),
|
|
272
|
+
"created_at": data.get("created_at", ""),
|
|
273
|
+
"source": "manifest",
|
|
274
|
+
}
|
|
275
|
+
return json.dumps(card, indent=2).encode("utf-8")
|
|
276
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
277
|
+
logger.warning("Failed to read manifest for identity card: %s", exc)
|
|
278
|
+
|
|
279
|
+
return json.dumps({"name": "unknown", "fingerprint": "", "source": "fallback"}).encode("utf-8")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _build_fingerprint_txt(agent_home: Path) -> bytes:
|
|
283
|
+
"""Extract the PGP fingerprint as plain text.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
agent_home: Agent home directory.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
UTF-8 encoded fingerprint bytes (newline-terminated).
|
|
290
|
+
"""
|
|
291
|
+
# Try CapAuth profile
|
|
292
|
+
capauth_profile = Path("~/.capauth/profile.json").expanduser()
|
|
293
|
+
if capauth_profile.exists():
|
|
294
|
+
try:
|
|
295
|
+
data = json.loads(capauth_profile.read_text(encoding="utf-8"))
|
|
296
|
+
fp = data.get("fingerprint", "")
|
|
297
|
+
if fp:
|
|
298
|
+
return (fp + "\n").encode("utf-8")
|
|
299
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
300
|
+
logger.warning("Failed to read CapAuth profile for fingerprint: %s", exc)
|
|
301
|
+
|
|
302
|
+
# Try manifest
|
|
303
|
+
manifest_path = agent_home / "manifest.json"
|
|
304
|
+
if manifest_path.exists():
|
|
305
|
+
try:
|
|
306
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
307
|
+
fp = data.get("identity", {}).get("fingerprint", "")
|
|
308
|
+
if fp:
|
|
309
|
+
return (fp + "\n").encode("utf-8")
|
|
310
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
311
|
+
logger.warning("Failed to read manifest for fingerprint: %s", exc)
|
|
312
|
+
|
|
313
|
+
return b"(no fingerprint)\n"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _list_inbox(agent_home: Path) -> List[str]:
|
|
317
|
+
"""List files in the SKComm inbox.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
agent_home: Agent home directory.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Sorted list of inbox filenames.
|
|
324
|
+
"""
|
|
325
|
+
inbox_dir = agent_home / "comms" / "inbox"
|
|
326
|
+
if not inbox_dir.exists():
|
|
327
|
+
return []
|
|
328
|
+
return sorted(p.name for p in inbox_dir.iterdir() if p.is_file())
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _read_inbox_file(agent_home: Path, filename: str) -> Optional[bytes]:
|
|
332
|
+
"""Read a message from the SKComm inbox.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
agent_home: Agent home directory.
|
|
336
|
+
filename: Name of the inbox file.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
File contents as bytes, or None if not found.
|
|
340
|
+
"""
|
|
341
|
+
path = agent_home / "comms" / "inbox" / filename
|
|
342
|
+
if not path.exists() or not path.is_file():
|
|
343
|
+
return None
|
|
344
|
+
try:
|
|
345
|
+
return path.read_bytes()
|
|
346
|
+
except OSError:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _list_documents(agent_home: Path) -> List[str]:
|
|
351
|
+
"""List signed documents in the sovereign documents directory.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
agent_home: Agent home directory.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Sorted list of document filenames.
|
|
358
|
+
"""
|
|
359
|
+
docs_dir = agent_home / "documents"
|
|
360
|
+
if not docs_dir.exists():
|
|
361
|
+
return []
|
|
362
|
+
return sorted(p.name for p in docs_dir.iterdir() if p.is_file())
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _read_document(agent_home: Path, filename: str) -> Optional[bytes]:
|
|
366
|
+
"""Read a signed document.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
agent_home: Agent home directory.
|
|
370
|
+
filename: Name of the document file.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
File contents as bytes, or None if not found.
|
|
374
|
+
"""
|
|
375
|
+
path = agent_home / "documents" / filename
|
|
376
|
+
if not path.exists() or not path.is_file():
|
|
377
|
+
return None
|
|
378
|
+
try:
|
|
379
|
+
return path.read_bytes()
|
|
380
|
+
except OSError:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _list_coordination_tasks(agent_home: Path) -> List[str]:
|
|
385
|
+
"""List coordination task files.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
agent_home: Agent home directory.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Sorted list of task JSON filenames.
|
|
392
|
+
"""
|
|
393
|
+
tasks_dir = agent_home / "coordination" / "tasks"
|
|
394
|
+
if not tasks_dir.exists():
|
|
395
|
+
return []
|
|
396
|
+
return sorted(p.name for p in tasks_dir.glob("*.json"))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _read_coordination_task(agent_home: Path, filename: str) -> Optional[bytes]:
|
|
400
|
+
"""Read a coordination task JSON file.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
agent_home: Agent home directory.
|
|
404
|
+
filename: Name of the task JSON file.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
File contents as bytes, or None if not found.
|
|
408
|
+
"""
|
|
409
|
+
path = agent_home / "coordination" / "tasks" / filename
|
|
410
|
+
if not path.exists():
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
return path.read_bytes()
|
|
414
|
+
except OSError:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# SKComm send helper
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _send_via_skcomm(agent_home: Path, recipient: str, message: str) -> bool:
|
|
424
|
+
"""Send a message via SKComm by writing to the outbox directory.
|
|
425
|
+
|
|
426
|
+
Attempts to use the skcapstone CLI for delivery. Falls back to writing
|
|
427
|
+
an envelope JSON file in the outbox directory.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
agent_home: Agent home directory.
|
|
431
|
+
recipient: Recipient agent name.
|
|
432
|
+
message: Message content to deliver.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
True if the message was queued successfully.
|
|
436
|
+
"""
|
|
437
|
+
# Try skcapstone comm send CLI
|
|
438
|
+
try:
|
|
439
|
+
result = subprocess.run(
|
|
440
|
+
["skcapstone", "comm", "send", recipient, "--message", message],
|
|
441
|
+
capture_output=True,
|
|
442
|
+
text=True,
|
|
443
|
+
timeout=10,
|
|
444
|
+
)
|
|
445
|
+
if result.returncode == 0:
|
|
446
|
+
logger.info("Sent message to %s via skcapstone CLI", recipient)
|
|
447
|
+
return True
|
|
448
|
+
logger.debug("skcapstone CLI send failed: %s", result.stderr)
|
|
449
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
450
|
+
logger.debug("skcapstone CLI unavailable: %s", exc)
|
|
451
|
+
|
|
452
|
+
# Fallback: write envelope JSON to outbox
|
|
453
|
+
outbox_dir = agent_home / "comms" / "outbox"
|
|
454
|
+
outbox_dir.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
456
|
+
envelope = {
|
|
457
|
+
"recipient": recipient,
|
|
458
|
+
"message": message,
|
|
459
|
+
"queued_at": ts,
|
|
460
|
+
"delivered": False,
|
|
461
|
+
}
|
|
462
|
+
envelope_name = f"{recipient}_{int(time.time())}.json"
|
|
463
|
+
envelope_path = outbox_dir / envelope_name
|
|
464
|
+
try:
|
|
465
|
+
envelope_path.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
|
|
466
|
+
logger.info("Queued message to %s at %s", recipient, envelope_path)
|
|
467
|
+
return True
|
|
468
|
+
except OSError as exc:
|
|
469
|
+
logger.error("Failed to queue message to %s: %s", recipient, exc)
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
# Path parser
|
|
475
|
+
# ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _parse_path(path: str) -> Tuple[str, ...]:
|
|
479
|
+
"""Parse a virtual FS path into clean components.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
path: POSIX path string (e.g. ``/memories/short/abc123.md``).
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Tuple of path components with empty strings removed.
|
|
486
|
+
"""
|
|
487
|
+
return tuple(p for p in path.strip("/").split("/") if p)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ---------------------------------------------------------------------------
|
|
491
|
+
# SovereignFS
|
|
492
|
+
# ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class SovereignFS:
|
|
496
|
+
"""FUSE Operations implementation for the sovereign virtual filesystem.
|
|
497
|
+
|
|
498
|
+
Exposes agent memories, identity, inbox, outbox, and coordination tasks
|
|
499
|
+
as a read-mostly virtual filesystem. Writing to ``/outbox/<agent>.msg``
|
|
500
|
+
delivers a message via SKComm.
|
|
501
|
+
|
|
502
|
+
This class is designed to be used with ``fusepy``:
|
|
503
|
+
|
|
504
|
+
.. code-block:: python
|
|
505
|
+
|
|
506
|
+
import fuse
|
|
507
|
+
fs = SovereignFS(agent_home=Path("~/.skcapstone").expanduser())
|
|
508
|
+
fuse.FUSE(fs, mount_point, nothreads=True, foreground=True)
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
agent_home: Sovereign agent home directory.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
def __init__(self, agent_home: Path) -> None:
|
|
515
|
+
self._home = agent_home
|
|
516
|
+
agent_name = os.environ.get("SKCAPSTONE_AGENT", "lumina")
|
|
517
|
+
self._memory_dir = agent_home / "agents" / agent_name / "memory"
|
|
518
|
+
# Buffer for outbox writes: maps virtual path → bytes written so far
|
|
519
|
+
self._outbox_buffers: Dict[str, bytes] = {}
|
|
520
|
+
|
|
521
|
+
# ------------------------------------------------------------------
|
|
522
|
+
# Internal helpers
|
|
523
|
+
# ------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def _memory_content(self, layer_slug: str, filename: str) -> Optional[bytes]:
|
|
526
|
+
"""Resolve and render a memory file.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
layer_slug: Virtual layer slug (``short``, ``mid``, or ``long``).
|
|
530
|
+
filename: Filename (``<id>.md``).
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Markdown bytes, or None if not found.
|
|
534
|
+
"""
|
|
535
|
+
if filename.endswith(".md"):
|
|
536
|
+
memory_id = filename[:-3]
|
|
537
|
+
else:
|
|
538
|
+
memory_id = filename
|
|
539
|
+
layer_value = _LAYER_SLUG_TO_VALUE.get(layer_slug)
|
|
540
|
+
if not layer_value:
|
|
541
|
+
return None
|
|
542
|
+
return _load_memory_file(self._memory_dir, layer_value, memory_id)
|
|
543
|
+
|
|
544
|
+
def _resolve_file_content(self, parts: Tuple[str, ...]) -> Optional[bytes]:
|
|
545
|
+
"""Resolve path components to file content.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
parts: Parsed path components.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
File content as bytes, or None if the path is not a file.
|
|
552
|
+
"""
|
|
553
|
+
if not parts:
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
top = parts[0]
|
|
557
|
+
|
|
558
|
+
# /memories/short|mid|long/<id>.md
|
|
559
|
+
if top == _MEMORIES_DIR and len(parts) == 3:
|
|
560
|
+
return self._memory_content(parts[1], parts[2])
|
|
561
|
+
|
|
562
|
+
# /identity/card.json or /identity/fingerprint.txt
|
|
563
|
+
if top == _IDENTITY_DIR and len(parts) == 2:
|
|
564
|
+
if parts[1] == "card.json":
|
|
565
|
+
return _build_identity_card(self._home)
|
|
566
|
+
if parts[1] == "fingerprint.txt":
|
|
567
|
+
return _build_fingerprint_txt(self._home)
|
|
568
|
+
|
|
569
|
+
# /inbox/<filename>
|
|
570
|
+
if top == _INBOX_DIR and len(parts) == 2:
|
|
571
|
+
return _read_inbox_file(self._home, parts[1])
|
|
572
|
+
|
|
573
|
+
# /documents/<filename>
|
|
574
|
+
if top == _DOCUMENTS_DIR and len(parts) == 2:
|
|
575
|
+
return _read_document(self._home, parts[1])
|
|
576
|
+
|
|
577
|
+
# /coordination/<task>.json
|
|
578
|
+
if top == _COORDINATION_DIR and len(parts) == 2:
|
|
579
|
+
return _read_coordination_task(self._home, parts[1])
|
|
580
|
+
|
|
581
|
+
# /outbox/<agent>.msg — reads back from in-memory buffer
|
|
582
|
+
if top == _OUTBOX_DIR and len(parts) == 2:
|
|
583
|
+
path_key = "/" + "/".join(parts)
|
|
584
|
+
return self._outbox_buffers.get(path_key, b"")
|
|
585
|
+
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
def _is_dir(self, parts: Tuple[str, ...]) -> bool:
|
|
589
|
+
"""Check if a set of path components resolves to a virtual directory.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
parts: Parsed path components.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
True if this path is a known virtual directory.
|
|
596
|
+
"""
|
|
597
|
+
if not parts:
|
|
598
|
+
return True # root
|
|
599
|
+
|
|
600
|
+
top = parts[0]
|
|
601
|
+
|
|
602
|
+
if len(parts) == 1:
|
|
603
|
+
return top in _TOP_LEVEL_DIRS
|
|
604
|
+
|
|
605
|
+
if top == _MEMORIES_DIR and len(parts) == 2:
|
|
606
|
+
return parts[1] in _MEMORY_SUBDIRS
|
|
607
|
+
|
|
608
|
+
return False
|
|
609
|
+
|
|
610
|
+
def _is_file(self, parts: Tuple[str, ...]) -> bool:
|
|
611
|
+
"""Check if path components resolve to a readable virtual file.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
parts: Parsed path components.
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
True if the path is a valid virtual file.
|
|
618
|
+
"""
|
|
619
|
+
return self._resolve_file_content(parts) is not None
|
|
620
|
+
|
|
621
|
+
def _file_size(self, parts: Tuple[str, ...]) -> int:
|
|
622
|
+
"""Return the byte size of a virtual file.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
parts: Parsed path components.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
Size in bytes (0 if content is unavailable).
|
|
629
|
+
"""
|
|
630
|
+
content = self._resolve_file_content(parts)
|
|
631
|
+
return len(content) if content is not None else 0
|
|
632
|
+
|
|
633
|
+
# ------------------------------------------------------------------
|
|
634
|
+
# FUSE Operations
|
|
635
|
+
# ------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
def getattr(self, path: str, fh: Optional[int] = None) -> Dict[str, Any]:
|
|
638
|
+
"""Return stat-like attributes for a path.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
path: Virtual filesystem path.
|
|
642
|
+
fh: Open file handle (unused).
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
Stat attribute dictionary.
|
|
646
|
+
|
|
647
|
+
Raises:
|
|
648
|
+
OSError: With ``errno.ENOENT`` if the path does not exist.
|
|
649
|
+
"""
|
|
650
|
+
parts = _parse_path(path)
|
|
651
|
+
|
|
652
|
+
if self._is_dir(parts):
|
|
653
|
+
nlink = (
|
|
654
|
+
2 + len(_MEMORY_SUBDIRS)
|
|
655
|
+
if parts and parts[0] == _MEMORIES_DIR and len(parts) == 1
|
|
656
|
+
else 2
|
|
657
|
+
)
|
|
658
|
+
return _dir_stat(nlink=nlink)
|
|
659
|
+
|
|
660
|
+
if self._is_file(parts):
|
|
661
|
+
size = self._file_size(parts)
|
|
662
|
+
writable = bool(parts) and parts[0] == _OUTBOX_DIR
|
|
663
|
+
return _file_stat(size=size, writable=writable)
|
|
664
|
+
|
|
665
|
+
raise OSError(errno.ENOENT, "No such file or directory", path)
|
|
666
|
+
|
|
667
|
+
def readdir(self, path: str, fh: Optional[int]) -> List[str]:
|
|
668
|
+
"""Return directory listing for a virtual path.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
path: Virtual filesystem path.
|
|
672
|
+
fh: Open file handle (unused).
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
List of entry names including ``.`` and ``..``.
|
|
676
|
+
|
|
677
|
+
Raises:
|
|
678
|
+
OSError: With ``errno.ENOENT`` if the path is not a directory.
|
|
679
|
+
"""
|
|
680
|
+
parts = _parse_path(path)
|
|
681
|
+
entries = [".", ".."]
|
|
682
|
+
|
|
683
|
+
if not parts:
|
|
684
|
+
# Root
|
|
685
|
+
entries.extend(_TOP_LEVEL_DIRS)
|
|
686
|
+
return entries
|
|
687
|
+
|
|
688
|
+
top = parts[0]
|
|
689
|
+
|
|
690
|
+
if top == _MEMORIES_DIR and len(parts) == 1:
|
|
691
|
+
entries.extend(_MEMORY_SUBDIRS)
|
|
692
|
+
return entries
|
|
693
|
+
|
|
694
|
+
if top == _MEMORIES_DIR and len(parts) == 2:
|
|
695
|
+
slug = parts[1]
|
|
696
|
+
layer_value = _LAYER_SLUG_TO_VALUE.get(slug)
|
|
697
|
+
if layer_value:
|
|
698
|
+
ids = _list_memory_ids(self._memory_dir, layer_value)
|
|
699
|
+
entries.extend(f"{mid}.md" for mid in ids)
|
|
700
|
+
return entries
|
|
701
|
+
|
|
702
|
+
if top == _IDENTITY_DIR and len(parts) == 1:
|
|
703
|
+
entries.extend(_IDENTITY_FILES)
|
|
704
|
+
return entries
|
|
705
|
+
|
|
706
|
+
if top == _INBOX_DIR and len(parts) == 1:
|
|
707
|
+
entries.extend(_list_inbox(self._home))
|
|
708
|
+
return entries
|
|
709
|
+
|
|
710
|
+
if top == _OUTBOX_DIR and len(parts) == 1:
|
|
711
|
+
# List any buffered outbox files
|
|
712
|
+
prefix = f"/{_OUTBOX_DIR}/"
|
|
713
|
+
entries.extend(k[len(prefix) :] for k in self._outbox_buffers if k.startswith(prefix))
|
|
714
|
+
return entries
|
|
715
|
+
|
|
716
|
+
if top == _DOCUMENTS_DIR and len(parts) == 1:
|
|
717
|
+
entries.extend(_list_documents(self._home))
|
|
718
|
+
return entries
|
|
719
|
+
|
|
720
|
+
if top == _COORDINATION_DIR and len(parts) == 1:
|
|
721
|
+
entries.extend(_list_coordination_tasks(self._home))
|
|
722
|
+
return entries
|
|
723
|
+
|
|
724
|
+
raise OSError(errno.ENOENT, "No such file or directory", path)
|
|
725
|
+
|
|
726
|
+
def open(self, path: str, flags: int) -> int:
|
|
727
|
+
"""Open a virtual file.
|
|
728
|
+
|
|
729
|
+
Only read and write flags are honoured; outbox files accept writes.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
path: Virtual filesystem path.
|
|
733
|
+
flags: Open flags bitmask (os.O_RDONLY, os.O_WRONLY, os.O_RDWR, etc.).
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
Always 0 (no per-fd state needed).
|
|
737
|
+
|
|
738
|
+
Raises:
|
|
739
|
+
OSError: With appropriate errno if the path is not accessible.
|
|
740
|
+
"""
|
|
741
|
+
parts = _parse_path(path)
|
|
742
|
+
|
|
743
|
+
is_write = bool(flags & (os.O_WRONLY | os.O_RDWR))
|
|
744
|
+
is_outbox = bool(parts) and parts[0] == _OUTBOX_DIR
|
|
745
|
+
|
|
746
|
+
if is_write:
|
|
747
|
+
if not is_outbox:
|
|
748
|
+
raise OSError(errno.EACCES, "Read-only filesystem", path)
|
|
749
|
+
# Initialize outbox buffer
|
|
750
|
+
self._outbox_buffers[path] = b""
|
|
751
|
+
return 0
|
|
752
|
+
|
|
753
|
+
if not self._is_file(parts):
|
|
754
|
+
raise OSError(errno.ENOENT, "No such file or directory", path)
|
|
755
|
+
|
|
756
|
+
return 0
|
|
757
|
+
|
|
758
|
+
def read(self, path: str, size: int, offset: int, fh: int) -> bytes:
|
|
759
|
+
"""Read bytes from a virtual file.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
path: Virtual filesystem path.
|
|
763
|
+
size: Maximum number of bytes to return.
|
|
764
|
+
offset: Byte offset to start reading from.
|
|
765
|
+
fh: Open file handle (unused).
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
Bytes slice from the file content.
|
|
769
|
+
|
|
770
|
+
Raises:
|
|
771
|
+
OSError: With ``errno.ENOENT`` if the path is not a file.
|
|
772
|
+
"""
|
|
773
|
+
parts = _parse_path(path)
|
|
774
|
+
content = self._resolve_file_content(parts)
|
|
775
|
+
if content is None:
|
|
776
|
+
raise OSError(errno.ENOENT, "No such file or directory", path)
|
|
777
|
+
return content[offset : offset + size]
|
|
778
|
+
|
|
779
|
+
def write(self, path: str, data: bytes, offset: int, fh: int) -> int:
|
|
780
|
+
"""Write bytes to an outbox file, buffering until flush.
|
|
781
|
+
|
|
782
|
+
Only ``/outbox/<agent_name>.msg`` paths are writable. On the first
|
|
783
|
+
write the buffer is initialised; subsequent writes append.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
path: Virtual filesystem path (must be under ``/outbox/``).
|
|
787
|
+
data: Bytes to write.
|
|
788
|
+
offset: Byte offset (used to detect new vs. appended writes).
|
|
789
|
+
fh: Open file handle (unused).
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
Number of bytes written.
|
|
793
|
+
|
|
794
|
+
Raises:
|
|
795
|
+
OSError: With ``errno.EACCES`` if the path is not in ``/outbox/``.
|
|
796
|
+
"""
|
|
797
|
+
parts = _parse_path(path)
|
|
798
|
+
if not parts or parts[0] != _OUTBOX_DIR:
|
|
799
|
+
raise OSError(errno.EACCES, "Read-only filesystem", path)
|
|
800
|
+
|
|
801
|
+
if path not in self._outbox_buffers or offset == 0:
|
|
802
|
+
self._outbox_buffers[path] = b""
|
|
803
|
+
|
|
804
|
+
buf = self._outbox_buffers.get(path, b"")
|
|
805
|
+
self._outbox_buffers[path] = buf[:offset] + data
|
|
806
|
+
return len(data)
|
|
807
|
+
|
|
808
|
+
def create(self, path: str, mode: int, fi: Optional[Any] = None) -> int:
|
|
809
|
+
"""Create a new outbox file.
|
|
810
|
+
|
|
811
|
+
Only ``/outbox/<agent_name>.msg`` paths may be created.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
path: Virtual filesystem path.
|
|
815
|
+
mode: File permission mode (stored but not enforced in virtual FS).
|
|
816
|
+
fi: FUSE file info structure (unused).
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
Always 0.
|
|
820
|
+
|
|
821
|
+
Raises:
|
|
822
|
+
OSError: With ``errno.EACCES`` if the path is not under ``/outbox/``.
|
|
823
|
+
"""
|
|
824
|
+
parts = _parse_path(path)
|
|
825
|
+
if not parts or parts[0] != _OUTBOX_DIR:
|
|
826
|
+
raise OSError(errno.EACCES, "Read-only filesystem", path)
|
|
827
|
+
|
|
828
|
+
self._outbox_buffers[path] = b""
|
|
829
|
+
return 0
|
|
830
|
+
|
|
831
|
+
def flush(self, path: str, fh: int) -> int:
|
|
832
|
+
"""Flush an outbox file buffer, delivering the message via SKComm.
|
|
833
|
+
|
|
834
|
+
Called when an outbox file handle is closed. The accumulated buffer
|
|
835
|
+
is interpreted as the message body; the filename (without ``.msg``)
|
|
836
|
+
is used as the recipient agent name.
|
|
837
|
+
|
|
838
|
+
Args:
|
|
839
|
+
path: Virtual filesystem path.
|
|
840
|
+
fh: Open file handle (unused).
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
Always 0.
|
|
844
|
+
"""
|
|
845
|
+
parts = _parse_path(path)
|
|
846
|
+
if not parts or parts[0] != _OUTBOX_DIR:
|
|
847
|
+
return 0
|
|
848
|
+
|
|
849
|
+
filename = parts[-1]
|
|
850
|
+
# Strip .msg suffix to get the recipient name
|
|
851
|
+
recipient = filename[:-4] if filename.endswith(".msg") else filename
|
|
852
|
+
|
|
853
|
+
message_bytes = self._outbox_buffers.get(path, b"")
|
|
854
|
+
if not message_bytes:
|
|
855
|
+
return 0
|
|
856
|
+
|
|
857
|
+
try:
|
|
858
|
+
message = message_bytes.decode("utf-8").strip()
|
|
859
|
+
except UnicodeDecodeError:
|
|
860
|
+
logger.warning("Outbox message for %s is not valid UTF-8", recipient)
|
|
861
|
+
return 0
|
|
862
|
+
|
|
863
|
+
if message:
|
|
864
|
+
_send_via_skcomm(self._home, recipient, message)
|
|
865
|
+
|
|
866
|
+
# Clear buffer after sending
|
|
867
|
+
self._outbox_buffers.pop(path, None)
|
|
868
|
+
return 0
|
|
869
|
+
|
|
870
|
+
def release(self, path: str, fh: int) -> int:
|
|
871
|
+
"""Release a file handle, flushing outbox if needed.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
path: Virtual filesystem path.
|
|
875
|
+
fh: Open file handle.
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
Always 0.
|
|
879
|
+
"""
|
|
880
|
+
# Flush any remaining outbox data
|
|
881
|
+
self.flush(path, fh)
|
|
882
|
+
return 0
|
|
883
|
+
|
|
884
|
+
def truncate(self, path: str, length: int, fh: Optional[int] = None) -> None:
|
|
885
|
+
"""Truncate a file in the outbox buffer.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
path: Virtual filesystem path.
|
|
889
|
+
length: Target length in bytes.
|
|
890
|
+
fh: Open file handle (unused).
|
|
891
|
+
|
|
892
|
+
Raises:
|
|
893
|
+
OSError: With ``errno.EACCES`` if the path is not under ``/outbox/``.
|
|
894
|
+
"""
|
|
895
|
+
parts = _parse_path(path)
|
|
896
|
+
if not parts or parts[0] != _OUTBOX_DIR:
|
|
897
|
+
raise OSError(errno.EACCES, "Read-only filesystem", path)
|
|
898
|
+
|
|
899
|
+
buf = self._outbox_buffers.get(path, b"")
|
|
900
|
+
self._outbox_buffers[path] = buf[:length]
|
|
901
|
+
|
|
902
|
+
# Pass-through stubs for operations that the kernel may call
|
|
903
|
+
def chmod(self, path: str, mode: int) -> int:
|
|
904
|
+
"""Ignore chmod on the virtual filesystem."""
|
|
905
|
+
return 0
|
|
906
|
+
|
|
907
|
+
def chown(self, path: str, uid: int, gid: int) -> int:
|
|
908
|
+
"""Ignore chown on the virtual filesystem."""
|
|
909
|
+
return 0
|
|
910
|
+
|
|
911
|
+
def utimens(self, path: str, times: Optional[Tuple[float, float]] = None) -> int:
|
|
912
|
+
"""Ignore utimens on the virtual filesystem."""
|
|
913
|
+
return 0
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# ---------------------------------------------------------------------------
|
|
917
|
+
# FUSEDaemon — lifecycle manager
|
|
918
|
+
# ---------------------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
class FUSEDaemon:
|
|
922
|
+
"""Lifecycle manager for the SovereignFS FUSE mount.
|
|
923
|
+
|
|
924
|
+
Handles mounting, unmounting, and status checks for the sovereign
|
|
925
|
+
virtual filesystem.
|
|
926
|
+
|
|
927
|
+
Args:
|
|
928
|
+
mount_point: Directory to mount the filesystem at.
|
|
929
|
+
Defaults to ``~/.sovereign/mount/``.
|
|
930
|
+
agent_home: Agent home directory.
|
|
931
|
+
Defaults to ``~/.skcapstone``.
|
|
932
|
+
"""
|
|
933
|
+
|
|
934
|
+
_PID_FILE = "fuse.pid"
|
|
935
|
+
_STATE_FILE = "fuse_state.json"
|
|
936
|
+
|
|
937
|
+
def __init__(
|
|
938
|
+
self,
|
|
939
|
+
mount_point: Optional[Path] = None,
|
|
940
|
+
agent_home: Optional[Path] = None,
|
|
941
|
+
) -> None:
|
|
942
|
+
self._mount_point = (mount_point or Path("~/.sovereign/mount")).expanduser()
|
|
943
|
+
self._agent_home = (agent_home or Path("~/.skcapstone")).expanduser()
|
|
944
|
+
self._state_dir = self._agent_home / "fuse"
|
|
945
|
+
|
|
946
|
+
def _state_file(self) -> Path:
|
|
947
|
+
"""Path to the FUSE daemon state file.
|
|
948
|
+
|
|
949
|
+
Returns:
|
|
950
|
+
Absolute path to the JSON state file.
|
|
951
|
+
"""
|
|
952
|
+
return self._state_dir / self._STATE_FILE
|
|
953
|
+
|
|
954
|
+
def _pid_file(self) -> Path:
|
|
955
|
+
"""Path to the FUSE daemon PID file.
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
Absolute path to the PID file.
|
|
959
|
+
"""
|
|
960
|
+
return self._state_dir / self._PID_FILE
|
|
961
|
+
|
|
962
|
+
def _write_state(self, mounted: bool, pid: Optional[int] = None) -> None:
|
|
963
|
+
"""Persist the FUSE daemon state to disk.
|
|
964
|
+
|
|
965
|
+
Args:
|
|
966
|
+
mounted: Whether the filesystem is currently mounted.
|
|
967
|
+
pid: Process ID of the FUSE daemon (if any).
|
|
968
|
+
"""
|
|
969
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
970
|
+
state = {
|
|
971
|
+
"mounted": mounted,
|
|
972
|
+
"mount_point": str(self._mount_point),
|
|
973
|
+
"agent_home": str(self._agent_home),
|
|
974
|
+
"pid": pid,
|
|
975
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
976
|
+
}
|
|
977
|
+
self._state_file().write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
978
|
+
|
|
979
|
+
def _read_state(self) -> Optional[Dict[str, Any]]:
|
|
980
|
+
"""Read the FUSE daemon state from disk.
|
|
981
|
+
|
|
982
|
+
Returns:
|
|
983
|
+
State dictionary, or None if missing or corrupt.
|
|
984
|
+
"""
|
|
985
|
+
path = self._state_file()
|
|
986
|
+
if not path.exists():
|
|
987
|
+
return None
|
|
988
|
+
try:
|
|
989
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
990
|
+
except (json.JSONDecodeError, OSError):
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
def _is_mounted(self) -> bool:
|
|
994
|
+
"""Check whether the mount point is currently active.
|
|
995
|
+
|
|
996
|
+
Uses ``/proc/mounts`` on Linux for reliable detection.
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
True if the mount point appears to be mounted.
|
|
1000
|
+
"""
|
|
1001
|
+
mount_str = str(self._mount_point)
|
|
1002
|
+
|
|
1003
|
+
# Linux: parse /proc/mounts
|
|
1004
|
+
proc_mounts = Path("/proc/mounts")
|
|
1005
|
+
if proc_mounts.exists():
|
|
1006
|
+
try:
|
|
1007
|
+
for line in proc_mounts.read_text(encoding="utf-8").splitlines():
|
|
1008
|
+
parts = line.split()
|
|
1009
|
+
if len(parts) >= 2 and parts[1] == mount_str:
|
|
1010
|
+
return True
|
|
1011
|
+
except OSError as exc:
|
|
1012
|
+
logger.warning("Failed to read /proc/mounts: %s", exc)
|
|
1013
|
+
return False
|
|
1014
|
+
|
|
1015
|
+
# macOS / other: use mount command
|
|
1016
|
+
try:
|
|
1017
|
+
result = subprocess.run(
|
|
1018
|
+
["mount"],
|
|
1019
|
+
capture_output=True,
|
|
1020
|
+
text=True,
|
|
1021
|
+
timeout=5,
|
|
1022
|
+
)
|
|
1023
|
+
return mount_str in result.stdout
|
|
1024
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
1025
|
+
return False
|
|
1026
|
+
|
|
1027
|
+
def start(self, foreground: bool = False) -> bool:
|
|
1028
|
+
"""Mount the sovereign virtual filesystem.
|
|
1029
|
+
|
|
1030
|
+
Attempts to import ``fuse`` (fusepy) and mount the SovereignFS
|
|
1031
|
+
filesystem at the configured mount point. If ``foreground=False``
|
|
1032
|
+
the mount runs as a daemon process.
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
foreground: If True, mount in the foreground (blocks until unmounted).
|
|
1036
|
+
Useful for debugging.
|
|
1037
|
+
|
|
1038
|
+
Returns:
|
|
1039
|
+
True if the mount was initiated successfully.
|
|
1040
|
+
"""
|
|
1041
|
+
try:
|
|
1042
|
+
import fuse as _fuse # type: ignore[import]
|
|
1043
|
+
except ImportError:
|
|
1044
|
+
logger.error("fusepy is not installed. Install with: pip install skcapstone[fuse]")
|
|
1045
|
+
return False
|
|
1046
|
+
|
|
1047
|
+
if self._is_mounted():
|
|
1048
|
+
logger.info("Already mounted at %s", self._mount_point)
|
|
1049
|
+
return True
|
|
1050
|
+
|
|
1051
|
+
self._mount_point.mkdir(parents=True, exist_ok=True)
|
|
1052
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
1053
|
+
|
|
1054
|
+
if foreground:
|
|
1055
|
+
logger.info("Mounting sovereign filesystem at %s (foreground)", self._mount_point)
|
|
1056
|
+
try:
|
|
1057
|
+
fs = SovereignFS(agent_home=self._agent_home)
|
|
1058
|
+
self._write_state(mounted=True, pid=os.getpid())
|
|
1059
|
+
_fuse.FUSE(
|
|
1060
|
+
fs,
|
|
1061
|
+
str(self._mount_point),
|
|
1062
|
+
nothreads=True,
|
|
1063
|
+
foreground=True,
|
|
1064
|
+
allow_other=False,
|
|
1065
|
+
)
|
|
1066
|
+
return True
|
|
1067
|
+
except Exception as exc:
|
|
1068
|
+
logger.error("Failed to mount filesystem: %s", exc)
|
|
1069
|
+
self._write_state(mounted=False)
|
|
1070
|
+
return False
|
|
1071
|
+
else:
|
|
1072
|
+
# Background mount: re-exec this function in a child process
|
|
1073
|
+
logger.info("Mounting sovereign filesystem at %s (background)", self._mount_point)
|
|
1074
|
+
try:
|
|
1075
|
+
proc = subprocess.Popen(
|
|
1076
|
+
[
|
|
1077
|
+
sys.executable,
|
|
1078
|
+
"-c",
|
|
1079
|
+
(
|
|
1080
|
+
"from pathlib import Path; "
|
|
1081
|
+
"from skcapstone.fuse_mount import FUSEDaemon; "
|
|
1082
|
+
f"FUSEDaemon("
|
|
1083
|
+
f" mount_point=Path({str(self._mount_point)!r}), "
|
|
1084
|
+
f" agent_home=Path({str(self._agent_home)!r})"
|
|
1085
|
+
f").start(foreground=True)"
|
|
1086
|
+
),
|
|
1087
|
+
],
|
|
1088
|
+
start_new_session=True,
|
|
1089
|
+
stdout=subprocess.DEVNULL,
|
|
1090
|
+
stderr=subprocess.DEVNULL,
|
|
1091
|
+
)
|
|
1092
|
+
self._write_state(mounted=True, pid=proc.pid)
|
|
1093
|
+
self._pid_file().write_text(str(proc.pid), encoding="utf-8")
|
|
1094
|
+
logger.info("FUSE daemon started with pid %d", proc.pid)
|
|
1095
|
+
return True
|
|
1096
|
+
except OSError as exc:
|
|
1097
|
+
logger.error("Failed to start FUSE daemon: %s", exc)
|
|
1098
|
+
self._write_state(mounted=False)
|
|
1099
|
+
return False
|
|
1100
|
+
|
|
1101
|
+
def stop(self) -> bool:
|
|
1102
|
+
"""Unmount the sovereign virtual filesystem.
|
|
1103
|
+
|
|
1104
|
+
Attempts ``fusermount -u`` (Linux) or ``umount`` (macOS).
|
|
1105
|
+
|
|
1106
|
+
Returns:
|
|
1107
|
+
True if the filesystem was successfully unmounted.
|
|
1108
|
+
"""
|
|
1109
|
+
if not self._is_mounted():
|
|
1110
|
+
logger.info("Not mounted at %s", self._mount_point)
|
|
1111
|
+
self._write_state(mounted=False)
|
|
1112
|
+
return True
|
|
1113
|
+
|
|
1114
|
+
mount_str = str(self._mount_point)
|
|
1115
|
+
|
|
1116
|
+
# Linux: fusermount
|
|
1117
|
+
for cmd in (["fusermount", "-u", mount_str], ["umount", mount_str]):
|
|
1118
|
+
try:
|
|
1119
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
1120
|
+
if result.returncode == 0:
|
|
1121
|
+
logger.info("Unmounted %s", mount_str)
|
|
1122
|
+
self._write_state(mounted=False)
|
|
1123
|
+
return True
|
|
1124
|
+
logger.debug(
|
|
1125
|
+
"%s failed (rc=%d): %s",
|
|
1126
|
+
" ".join(cmd),
|
|
1127
|
+
result.returncode,
|
|
1128
|
+
result.stderr.strip(),
|
|
1129
|
+
)
|
|
1130
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
1131
|
+
logger.debug("Unmount command %s failed: %s", cmd, exc)
|
|
1132
|
+
|
|
1133
|
+
logger.error("Could not unmount %s — try: fusermount -u %s", mount_str, mount_str)
|
|
1134
|
+
return False
|
|
1135
|
+
|
|
1136
|
+
def status(self) -> Dict[str, Any]:
|
|
1137
|
+
"""Return the current FUSE daemon status.
|
|
1138
|
+
|
|
1139
|
+
Returns:
|
|
1140
|
+
Dictionary with keys:
|
|
1141
|
+
- ``mounted`` (bool): Whether the FS is currently mounted.
|
|
1142
|
+
- ``mount_point`` (str): Mount point path.
|
|
1143
|
+
- ``agent_home`` (str): Agent home path.
|
|
1144
|
+
- ``pid`` (int | None): Daemon process ID, if known.
|
|
1145
|
+
- ``updated_at`` (str | None): Last state update timestamp.
|
|
1146
|
+
"""
|
|
1147
|
+
state = self._read_state() or {}
|
|
1148
|
+
mounted = self._is_mounted()
|
|
1149
|
+
|
|
1150
|
+
return {
|
|
1151
|
+
"mounted": mounted,
|
|
1152
|
+
"mount_point": str(self._mount_point),
|
|
1153
|
+
"agent_home": str(self._agent_home),
|
|
1154
|
+
"pid": state.get("pid"),
|
|
1155
|
+
"updated_at": state.get("updated_at"),
|
|
1156
|
+
}
|