@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,516 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sovereign pub/sub — lightweight real-time messaging for agent meshes.
|
|
3
|
+
|
|
4
|
+
Topic-based publish/subscribe built on the file transport layer.
|
|
5
|
+
Designed for 100+ node scale without requiring a central broker.
|
|
6
|
+
Each agent manages its own subscriptions and topic inboxes.
|
|
7
|
+
|
|
8
|
+
Architecture:
|
|
9
|
+
Publishers write topic messages to a shared topic directory.
|
|
10
|
+
Subscribers poll their subscribed topics or register callbacks.
|
|
11
|
+
Syncthing distributes topic directories across the mesh.
|
|
12
|
+
|
|
13
|
+
Storage layout:
|
|
14
|
+
~/.skcapstone/pubsub/
|
|
15
|
+
├── subscriptions.json # Agent's active subscriptions
|
|
16
|
+
├── topics/ # Topic message directories
|
|
17
|
+
│ ├── system.health/ # Topic: system.health
|
|
18
|
+
│ │ ├── msg-<uuid>.json
|
|
19
|
+
│ │ └── ...
|
|
20
|
+
│ └── team.dev/ # Topic: team.dev
|
|
21
|
+
│ └── ...
|
|
22
|
+
└── dead-letter/ # Undeliverable messages
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
bus = PubSub(home, agent_name="opus")
|
|
26
|
+
bus.subscribe("system.health")
|
|
27
|
+
bus.subscribe("team.*") # wildcard
|
|
28
|
+
bus.publish("system.health", {"status": "alive", "load": 0.4})
|
|
29
|
+
messages = bus.poll("system.health", since=last_check)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import fnmatch
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
import uuid
|
|
40
|
+
from datetime import datetime, timedelta, timezone
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Callable, Optional
|
|
43
|
+
|
|
44
|
+
from pydantic import BaseModel, Field
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger("skcapstone.pubsub")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Models
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class TopicMessage(BaseModel):
|
|
54
|
+
"""A single published message on a topic."""
|
|
55
|
+
|
|
56
|
+
message_id: str = Field(default_factory=lambda: str(uuid.uuid4())[:12])
|
|
57
|
+
topic: str
|
|
58
|
+
sender: str
|
|
59
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
60
|
+
published_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
61
|
+
ttl_seconds: int = Field(default=86400, description="Message expiry (default 24h)")
|
|
62
|
+
tags: list[str] = Field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_expired(self) -> bool:
|
|
66
|
+
"""Check if this message has expired."""
|
|
67
|
+
expires = self.published_at + timedelta(seconds=self.ttl_seconds)
|
|
68
|
+
return datetime.now(timezone.utc) > expires
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Subscription(BaseModel):
|
|
72
|
+
"""An agent's subscription to a topic pattern."""
|
|
73
|
+
|
|
74
|
+
pattern: str = Field(description="Topic name or glob pattern (e.g., 'team.*')")
|
|
75
|
+
subscribed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
76
|
+
last_read: Optional[datetime] = None
|
|
77
|
+
message_count: int = 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# PubSub
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
class PubSub:
|
|
85
|
+
"""Sovereign publish/subscribe message bus.
|
|
86
|
+
|
|
87
|
+
File-based, mesh-friendly, zero-broker architecture.
|
|
88
|
+
Each agent runs its own PubSub instance that reads from
|
|
89
|
+
shared topic directories (distributed via Syncthing).
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
home: Agent home directory (~/.skcapstone).
|
|
93
|
+
agent_name: Name of the local agent.
|
|
94
|
+
max_topic_messages: Maximum messages per topic before pruning.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
home: Path,
|
|
100
|
+
agent_name: str = "anonymous",
|
|
101
|
+
max_topic_messages: int = 1000,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._home = home
|
|
104
|
+
self._agent = agent_name
|
|
105
|
+
self._max_messages = max_topic_messages
|
|
106
|
+
self._pubsub_dir = home / "pubsub"
|
|
107
|
+
self._topics_dir = self._pubsub_dir / "topics"
|
|
108
|
+
self._dead_letter_dir = self._pubsub_dir / "dead-letter"
|
|
109
|
+
self._subs_file = self._pubsub_dir / "subscriptions.json"
|
|
110
|
+
self._callbacks: dict[str, list[Callable]] = {}
|
|
111
|
+
|
|
112
|
+
def initialize(self) -> None:
|
|
113
|
+
"""Create the pub/sub directory structure."""
|
|
114
|
+
self._pubsub_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
self._topics_dir.mkdir(exist_ok=True)
|
|
116
|
+
self._dead_letter_dir.mkdir(exist_ok=True)
|
|
117
|
+
|
|
118
|
+
def publish(
|
|
119
|
+
self,
|
|
120
|
+
topic: str,
|
|
121
|
+
payload: dict[str, Any],
|
|
122
|
+
ttl_seconds: int = 86400,
|
|
123
|
+
tags: Optional[list[str]] = None,
|
|
124
|
+
) -> TopicMessage:
|
|
125
|
+
"""Publish a message to a topic.
|
|
126
|
+
|
|
127
|
+
Creates the topic directory if it doesn't exist and writes
|
|
128
|
+
the message as a JSON file. Prunes old messages if the topic
|
|
129
|
+
exceeds max_topic_messages.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
topic: Topic name (e.g., 'system.health', 'team.dev').
|
|
133
|
+
payload: Message payload dict.
|
|
134
|
+
ttl_seconds: Message time-to-live in seconds.
|
|
135
|
+
tags: Optional tags for filtering.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The published TopicMessage.
|
|
139
|
+
"""
|
|
140
|
+
self.initialize()
|
|
141
|
+
|
|
142
|
+
msg = TopicMessage(
|
|
143
|
+
topic=topic,
|
|
144
|
+
sender=self._agent,
|
|
145
|
+
payload=payload,
|
|
146
|
+
ttl_seconds=ttl_seconds,
|
|
147
|
+
tags=tags or [],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
topic_dir = self._topics_dir / _sanitize_topic(topic)
|
|
151
|
+
topic_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
filename = f"msg-{msg.message_id}.json"
|
|
154
|
+
tmp_path = topic_dir / f".{filename}.tmp"
|
|
155
|
+
final_path = topic_dir / filename
|
|
156
|
+
|
|
157
|
+
tmp_path.write_text(
|
|
158
|
+
msg.model_dump_json(indent=2),
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
)
|
|
161
|
+
tmp_path.rename(final_path)
|
|
162
|
+
|
|
163
|
+
self._prune_topic(topic_dir)
|
|
164
|
+
|
|
165
|
+
logger.debug("Published to '%s': %s", topic, msg.message_id)
|
|
166
|
+
return msg
|
|
167
|
+
|
|
168
|
+
def subscribe(self, pattern: str) -> Subscription:
|
|
169
|
+
"""Subscribe to a topic or topic pattern.
|
|
170
|
+
|
|
171
|
+
Supports glob patterns (e.g., 'team.*', 'system.health',
|
|
172
|
+
'*.critical'). The subscription is persisted to disk.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
pattern: Topic name or glob pattern.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The new or existing Subscription.
|
|
179
|
+
"""
|
|
180
|
+
self.initialize()
|
|
181
|
+
subs = self._load_subscriptions()
|
|
182
|
+
|
|
183
|
+
existing = subs.get(pattern)
|
|
184
|
+
if existing:
|
|
185
|
+
return existing
|
|
186
|
+
|
|
187
|
+
sub = Subscription(pattern=pattern)
|
|
188
|
+
subs[pattern] = sub
|
|
189
|
+
self._save_subscriptions(subs)
|
|
190
|
+
|
|
191
|
+
logger.info("Agent '%s' subscribed to '%s'", self._agent, pattern)
|
|
192
|
+
return sub
|
|
193
|
+
|
|
194
|
+
def unsubscribe(self, pattern: str) -> bool:
|
|
195
|
+
"""Remove a subscription.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
pattern: The pattern to unsubscribe from.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
True if the subscription existed and was removed.
|
|
202
|
+
"""
|
|
203
|
+
subs = self._load_subscriptions()
|
|
204
|
+
if pattern not in subs:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
del subs[pattern]
|
|
208
|
+
self._save_subscriptions(subs)
|
|
209
|
+
self._callbacks.pop(pattern, None)
|
|
210
|
+
logger.info("Agent '%s' unsubscribed from '%s'", self._agent, pattern)
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
def poll(
|
|
214
|
+
self,
|
|
215
|
+
topic: Optional[str] = None,
|
|
216
|
+
since: Optional[datetime] = None,
|
|
217
|
+
limit: int = 100,
|
|
218
|
+
) -> list[TopicMessage]:
|
|
219
|
+
"""Poll for new messages on subscribed topics.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
topic: Specific topic to poll (None = all subscribed).
|
|
223
|
+
since: Only return messages after this timestamp.
|
|
224
|
+
limit: Maximum messages to return.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
List of TopicMessage objects, newest first.
|
|
228
|
+
"""
|
|
229
|
+
self.initialize()
|
|
230
|
+
|
|
231
|
+
if topic:
|
|
232
|
+
topics = [topic]
|
|
233
|
+
else:
|
|
234
|
+
subs = self._load_subscriptions()
|
|
235
|
+
topics = self._resolve_subscribed_topics(subs)
|
|
236
|
+
|
|
237
|
+
messages: list[TopicMessage] = []
|
|
238
|
+
for t in topics:
|
|
239
|
+
topic_dir = self._topics_dir / _sanitize_topic(t)
|
|
240
|
+
if not topic_dir.is_dir():
|
|
241
|
+
continue
|
|
242
|
+
for msg_file in sorted(topic_dir.glob("msg-*.json")):
|
|
243
|
+
try:
|
|
244
|
+
data = json.loads(msg_file.read_text(encoding="utf-8"))
|
|
245
|
+
msg = TopicMessage.model_validate(data)
|
|
246
|
+
if msg.is_expired:
|
|
247
|
+
continue
|
|
248
|
+
if since and msg.published_at <= since:
|
|
249
|
+
continue
|
|
250
|
+
messages.append(msg)
|
|
251
|
+
except (json.JSONDecodeError, Exception) as exc:
|
|
252
|
+
logger.warning("Skipping invalid message %s: %s", msg_file.name, exc)
|
|
253
|
+
|
|
254
|
+
messages.sort(key=lambda m: m.published_at, reverse=True)
|
|
255
|
+
|
|
256
|
+
if topic:
|
|
257
|
+
subs = self._load_subscriptions()
|
|
258
|
+
for pattern, sub in subs.items():
|
|
259
|
+
if fnmatch.fnmatch(topic, pattern):
|
|
260
|
+
sub.last_read = datetime.now(timezone.utc)
|
|
261
|
+
sub.message_count += len(messages[:limit])
|
|
262
|
+
self._save_subscriptions(subs)
|
|
263
|
+
|
|
264
|
+
return messages[:limit]
|
|
265
|
+
|
|
266
|
+
def on_message(self, pattern: str, callback: Callable[[TopicMessage], None]) -> None:
|
|
267
|
+
"""Register a callback for messages matching a pattern.
|
|
268
|
+
|
|
269
|
+
Callbacks are triggered during poll_and_dispatch().
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
pattern: Topic pattern to match.
|
|
273
|
+
callback: Function called with each matching TopicMessage.
|
|
274
|
+
"""
|
|
275
|
+
if pattern not in self._callbacks:
|
|
276
|
+
self._callbacks[pattern] = []
|
|
277
|
+
self._callbacks[pattern].append(callback)
|
|
278
|
+
self.subscribe(pattern)
|
|
279
|
+
|
|
280
|
+
def poll_and_dispatch(self, since: Optional[datetime] = None) -> int:
|
|
281
|
+
"""Poll all subscriptions and dispatch to registered callbacks.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
since: Only process messages after this timestamp.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Number of messages dispatched.
|
|
288
|
+
"""
|
|
289
|
+
dispatched = 0
|
|
290
|
+
messages = self.poll(since=since)
|
|
291
|
+
|
|
292
|
+
for msg in messages:
|
|
293
|
+
for pattern, callbacks in self._callbacks.items():
|
|
294
|
+
if fnmatch.fnmatch(msg.topic, pattern):
|
|
295
|
+
for cb in callbacks:
|
|
296
|
+
try:
|
|
297
|
+
cb(msg)
|
|
298
|
+
dispatched += 1
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
logger.error(
|
|
301
|
+
"Callback error for '%s' on '%s': %s",
|
|
302
|
+
pattern, msg.topic, exc,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return dispatched
|
|
306
|
+
|
|
307
|
+
def list_topics(self) -> list[dict[str, Any]]:
|
|
308
|
+
"""List all known topics with message counts.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
List of dicts with topic name, message count, and latest timestamp.
|
|
312
|
+
"""
|
|
313
|
+
self.initialize()
|
|
314
|
+
topics: list[dict[str, Any]] = []
|
|
315
|
+
|
|
316
|
+
if not self._topics_dir.is_dir():
|
|
317
|
+
return topics
|
|
318
|
+
|
|
319
|
+
for topic_dir in sorted(self._topics_dir.iterdir()):
|
|
320
|
+
if not topic_dir.is_dir():
|
|
321
|
+
continue
|
|
322
|
+
msg_files = list(topic_dir.glob("msg-*.json"))
|
|
323
|
+
latest = None
|
|
324
|
+
if msg_files:
|
|
325
|
+
try:
|
|
326
|
+
newest = max(msg_files, key=lambda f: f.stat().st_mtime)
|
|
327
|
+
data = json.loads(newest.read_text(encoding="utf-8"))
|
|
328
|
+
latest = data.get("published_at")
|
|
329
|
+
except (json.JSONDecodeError, OSError):
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
topics.append({
|
|
333
|
+
"topic": _unsanitize_topic(topic_dir.name),
|
|
334
|
+
"messages": len(msg_files),
|
|
335
|
+
"latest": latest,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
return topics
|
|
339
|
+
|
|
340
|
+
def list_subscriptions(self) -> dict[str, Subscription]:
|
|
341
|
+
"""Return the agent's current subscriptions."""
|
|
342
|
+
return self._load_subscriptions()
|
|
343
|
+
|
|
344
|
+
def purge_expired(self) -> int:
|
|
345
|
+
"""Remove expired messages from all topics.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Number of expired messages removed.
|
|
349
|
+
"""
|
|
350
|
+
removed = 0
|
|
351
|
+
if not self._topics_dir.is_dir():
|
|
352
|
+
return removed
|
|
353
|
+
|
|
354
|
+
for topic_dir in self._topics_dir.iterdir():
|
|
355
|
+
if not topic_dir.is_dir():
|
|
356
|
+
continue
|
|
357
|
+
for msg_file in topic_dir.glob("msg-*.json"):
|
|
358
|
+
try:
|
|
359
|
+
data = json.loads(msg_file.read_text(encoding="utf-8"))
|
|
360
|
+
msg = TopicMessage.model_validate(data)
|
|
361
|
+
if msg.is_expired:
|
|
362
|
+
msg_file.unlink()
|
|
363
|
+
removed += 1
|
|
364
|
+
except (json.JSONDecodeError, Exception):
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
if removed:
|
|
368
|
+
logger.info("Purged %d expired messages", removed)
|
|
369
|
+
return removed
|
|
370
|
+
|
|
371
|
+
def status(self) -> dict[str, Any]:
|
|
372
|
+
"""Return pub/sub status summary."""
|
|
373
|
+
subs = self._load_subscriptions()
|
|
374
|
+
topics = self.list_topics()
|
|
375
|
+
total_messages = sum(t["messages"] for t in topics)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
"agent": self._agent,
|
|
379
|
+
"subscriptions": len(subs),
|
|
380
|
+
"topics": len(topics),
|
|
381
|
+
"total_messages": total_messages,
|
|
382
|
+
"callbacks_registered": sum(len(cbs) for cbs in self._callbacks.values()),
|
|
383
|
+
"pubsub_dir": str(self._pubsub_dir),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
def topic_stats(self) -> list[dict[str, Any]]:
|
|
387
|
+
"""Return per-topic statistics for live (non-expired) messages.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
List of dicts with:
|
|
391
|
+
- topic: topic name
|
|
392
|
+
- message_count: number of live (non-expired) messages
|
|
393
|
+
- oldest_message_age_seconds: seconds since oldest live message,
|
|
394
|
+
or None if no live messages exist
|
|
395
|
+
"""
|
|
396
|
+
self.initialize()
|
|
397
|
+
stats: list[dict[str, Any]] = []
|
|
398
|
+
|
|
399
|
+
if not self._topics_dir.is_dir():
|
|
400
|
+
return stats
|
|
401
|
+
|
|
402
|
+
now = datetime.now(timezone.utc)
|
|
403
|
+
|
|
404
|
+
for topic_dir in sorted(self._topics_dir.iterdir()):
|
|
405
|
+
if not topic_dir.is_dir():
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
live_timestamps: list[datetime] = []
|
|
409
|
+
for msg_file in topic_dir.glob("msg-*.json"):
|
|
410
|
+
try:
|
|
411
|
+
data = json.loads(msg_file.read_text(encoding="utf-8"))
|
|
412
|
+
msg = TopicMessage.model_validate(data)
|
|
413
|
+
if not msg.is_expired:
|
|
414
|
+
live_timestamps.append(msg.published_at)
|
|
415
|
+
except (json.JSONDecodeError, Exception):
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
oldest_age: Optional[float] = None
|
|
419
|
+
if live_timestamps:
|
|
420
|
+
oldest_age = (now - min(live_timestamps)).total_seconds()
|
|
421
|
+
|
|
422
|
+
stats.append({
|
|
423
|
+
"topic": _unsanitize_topic(topic_dir.name),
|
|
424
|
+
"message_count": len(live_timestamps),
|
|
425
|
+
"oldest_message_age_seconds": oldest_age,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
return stats
|
|
429
|
+
|
|
430
|
+
async def start_expiry_task(self, interval: int = 300) -> None:
|
|
431
|
+
"""Periodically purge expired messages from all topics.
|
|
432
|
+
|
|
433
|
+
Designed to be launched as a long-running asyncio background task::
|
|
434
|
+
|
|
435
|
+
asyncio.create_task(bus.start_expiry_task())
|
|
436
|
+
|
|
437
|
+
The first sweep runs after ``interval`` seconds, then repeats.
|
|
438
|
+
File I/O is offloaded to the default executor so the event loop
|
|
439
|
+
is not blocked.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
interval: Seconds between expiry sweeps (default 300).
|
|
443
|
+
"""
|
|
444
|
+
logger.info("PubSub expiry task started (interval=%ds)", interval)
|
|
445
|
+
loop = asyncio.get_running_loop()
|
|
446
|
+
while True:
|
|
447
|
+
await asyncio.sleep(interval)
|
|
448
|
+
try:
|
|
449
|
+
removed = await loop.run_in_executor(None, self.purge_expired)
|
|
450
|
+
if removed:
|
|
451
|
+
logger.info("Expiry sweep removed %d messages", removed)
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
logger.error("Expiry sweep failed: %s", exc)
|
|
454
|
+
|
|
455
|
+
# -------------------------------------------------------------------
|
|
456
|
+
# Internal helpers
|
|
457
|
+
# -------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def _load_subscriptions(self) -> dict[str, Subscription]:
|
|
460
|
+
"""Load subscriptions from disk."""
|
|
461
|
+
if not self._subs_file.exists():
|
|
462
|
+
return {}
|
|
463
|
+
try:
|
|
464
|
+
data = json.loads(self._subs_file.read_text(encoding="utf-8"))
|
|
465
|
+
return {k: Subscription.model_validate(v) for k, v in data.items()}
|
|
466
|
+
except (json.JSONDecodeError, Exception) as exc:
|
|
467
|
+
logger.warning("Failed to load subscriptions: %s", exc)
|
|
468
|
+
return {}
|
|
469
|
+
|
|
470
|
+
def _save_subscriptions(self, subs: dict[str, Subscription]) -> None:
|
|
471
|
+
"""Persist subscriptions to disk."""
|
|
472
|
+
self._pubsub_dir.mkdir(parents=True, exist_ok=True)
|
|
473
|
+
data = {k: v.model_dump(mode="json") for k, v in subs.items()}
|
|
474
|
+
self._subs_file.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
475
|
+
|
|
476
|
+
def _resolve_subscribed_topics(self, subs: dict[str, Subscription]) -> list[str]:
|
|
477
|
+
"""Resolve subscription patterns to actual topic directories."""
|
|
478
|
+
if not self._topics_dir.is_dir():
|
|
479
|
+
return []
|
|
480
|
+
|
|
481
|
+
all_topics = [
|
|
482
|
+
_unsanitize_topic(d.name)
|
|
483
|
+
for d in self._topics_dir.iterdir()
|
|
484
|
+
if d.is_dir()
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
matched: set[str] = set()
|
|
488
|
+
for pattern in subs:
|
|
489
|
+
for topic in all_topics:
|
|
490
|
+
if fnmatch.fnmatch(topic, pattern):
|
|
491
|
+
matched.add(topic)
|
|
492
|
+
|
|
493
|
+
return sorted(matched)
|
|
494
|
+
|
|
495
|
+
def _prune_topic(self, topic_dir: Path) -> None:
|
|
496
|
+
"""Remove oldest messages if topic exceeds max size."""
|
|
497
|
+
msg_files = sorted(topic_dir.glob("msg-*.json"), key=lambda f: f.stat().st_mtime)
|
|
498
|
+
excess = len(msg_files) - self._max_messages
|
|
499
|
+
if excess > 0:
|
|
500
|
+
for f in msg_files[:excess]:
|
|
501
|
+
f.unlink()
|
|
502
|
+
logger.debug("Pruned %d old messages from %s", excess, topic_dir.name)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
# Helpers
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
def _sanitize_topic(topic: str) -> str:
|
|
510
|
+
"""Convert topic name to filesystem-safe directory name."""
|
|
511
|
+
return topic.replace("/", "--").replace(" ", "_")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _unsanitize_topic(dirname: str) -> str:
|
|
515
|
+
"""Reverse of _sanitize_topic."""
|
|
516
|
+
return dirname.replace("--", "/").replace("_", " ")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Token-bucket rate limiter for the daemon HTTP API.
|
|
2
|
+
|
|
3
|
+
Each client IP gets an independent token bucket. Buckets refill
|
|
4
|
+
continuously at ``rate`` tokens/second up to ``capacity``.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
limiter = RateLimiter(requests_per_minute=100)
|
|
9
|
+
if not limiter.is_allowed("127.0.0.1"):
|
|
10
|
+
# return HTTP 429
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Dict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TokenBucket:
|
|
21
|
+
"""Single-IP token bucket.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
rate: Refill rate in tokens per second.
|
|
25
|
+
capacity: Maximum token capacity (also the initial fill level).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, rate: float, capacity: int) -> None:
|
|
29
|
+
if rate <= 0:
|
|
30
|
+
raise ValueError("rate must be positive")
|
|
31
|
+
if capacity <= 0:
|
|
32
|
+
raise ValueError("capacity must be positive")
|
|
33
|
+
self._rate = rate
|
|
34
|
+
self._capacity = capacity
|
|
35
|
+
self._tokens = float(capacity)
|
|
36
|
+
self._last_refill = time.monotonic()
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Public interface
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def consume(self, count: int = 1) -> bool:
|
|
44
|
+
"""Try to consume ``count`` tokens.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if the tokens were available and consumed, False otherwise.
|
|
48
|
+
"""
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._refill()
|
|
51
|
+
if self._tokens >= count:
|
|
52
|
+
self._tokens -= count
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def tokens(self) -> float:
|
|
58
|
+
"""Current token level (approximate — does not acquire the lock)."""
|
|
59
|
+
return self._tokens
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Private helpers
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def _refill(self) -> None:
|
|
66
|
+
now = time.monotonic()
|
|
67
|
+
elapsed = now - self._last_refill
|
|
68
|
+
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
|
|
69
|
+
self._last_refill = now
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RateLimiter:
|
|
73
|
+
"""Per-IP token-bucket rate limiter.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
requests_per_minute: Allowed requests per minute per IP (default 100).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, requests_per_minute: int = 100) -> None:
|
|
80
|
+
if requests_per_minute <= 0:
|
|
81
|
+
raise ValueError("requests_per_minute must be positive")
|
|
82
|
+
self._rpm = requests_per_minute
|
|
83
|
+
self._rate = requests_per_minute / 60.0 # tokens per second
|
|
84
|
+
self._capacity = requests_per_minute
|
|
85
|
+
self._buckets: Dict[str, TokenBucket] = {}
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Public interface
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def is_allowed(self, ip: str) -> bool:
|
|
93
|
+
"""Return True if the request from ``ip`` is within the rate limit."""
|
|
94
|
+
bucket = self._get_or_create(ip)
|
|
95
|
+
return bucket.consume()
|
|
96
|
+
|
|
97
|
+
def reset(self, ip: str) -> None:
|
|
98
|
+
"""Remove the bucket for ``ip``, clearing its history."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._buckets.pop(ip, None)
|
|
101
|
+
|
|
102
|
+
def clear(self) -> None:
|
|
103
|
+
"""Remove all buckets (useful in tests)."""
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._buckets.clear()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def requests_per_minute(self) -> int:
|
|
109
|
+
return self._rpm
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Private helpers
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _get_or_create(self, ip: str) -> TokenBucket:
|
|
116
|
+
with self._lock:
|
|
117
|
+
if ip not in self._buckets:
|
|
118
|
+
self._buckets[ip] = TokenBucket(self._rate, self._capacity)
|
|
119
|
+
return self._buckets[ip]
|