@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,2494 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SKCapstone Daemon — the always-on sovereign agent.
|
|
3
|
+
|
|
4
|
+
Runs as a background process, continuously polling for
|
|
5
|
+
incoming messages, scheduling vault sync, monitoring
|
|
6
|
+
transport health, and exposing a local HTTP API for
|
|
7
|
+
connectors to query agent state.
|
|
8
|
+
|
|
9
|
+
This is what turns a CLI tool into a living agent.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import queue
|
|
20
|
+
import re
|
|
21
|
+
import signal
|
|
22
|
+
import struct
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from . import AGENT_HOME, SHARED_ROOT
|
|
33
|
+
from . import activity as _activity
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("skcapstone.daemon")
|
|
36
|
+
|
|
37
|
+
_PEER_NAME_SAFE_RE = re.compile(r"[^a-zA-Z0-9_\-@\.]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sanitize_peer(peer: str) -> str:
|
|
41
|
+
"""Sanitize a peer name for safe filesystem use (path-traversal prevention).
|
|
42
|
+
|
|
43
|
+
Strips path separators, null bytes, and characters outside the safe set.
|
|
44
|
+
Returns empty string if the result would be empty.
|
|
45
|
+
"""
|
|
46
|
+
if not peer or not isinstance(peer, str):
|
|
47
|
+
return ""
|
|
48
|
+
sanitized = peer.replace("\x00", "").replace("/", "").replace("\\", "")
|
|
49
|
+
sanitized = _PEER_NAME_SAFE_RE.sub("", sanitized)
|
|
50
|
+
sanitized = sanitized.strip(".")
|
|
51
|
+
return sanitized[:64]
|
|
52
|
+
|
|
53
|
+
DEFAULT_PORT = 7777
|
|
54
|
+
PID_FILE = "daemon.pid"
|
|
55
|
+
LOG_DIR = "logs"
|
|
56
|
+
|
|
57
|
+
# ── WebSocket helpers (RFC 6455, stdlib-only) ─────────────────────────────────
|
|
58
|
+
|
|
59
|
+
_WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ws_accept_key(key: str) -> str:
|
|
63
|
+
"""Return the Sec-WebSocket-Accept value for a given client key."""
|
|
64
|
+
raw = hashlib.sha1((key + _WS_MAGIC).encode("utf-8")).digest()
|
|
65
|
+
return base64.b64encode(raw).decode("ascii")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ws_encode_frame(payload: bytes) -> bytes:
|
|
69
|
+
"""Encode a WebSocket text frame (server→client, no masking)."""
|
|
70
|
+
n = len(payload)
|
|
71
|
+
if n < 126:
|
|
72
|
+
return struct.pack("BB", 0x81, n) + payload
|
|
73
|
+
if n < 65536:
|
|
74
|
+
return struct.pack("!BBH", 0x81, 126, n) + payload
|
|
75
|
+
return struct.pack("!BBQ", 0x81, 127, n) + payload
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ws_encode_close() -> bytes:
|
|
79
|
+
"""Return a WebSocket close frame."""
|
|
80
|
+
return struct.pack("BB", 0x88, 0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ws_recv_exact(sock, n: int):
|
|
84
|
+
"""Read exactly n bytes from sock; return bytes or None on EOF."""
|
|
85
|
+
buf = b""
|
|
86
|
+
while len(buf) < n:
|
|
87
|
+
chunk = sock.recv(n - len(buf))
|
|
88
|
+
if not chunk:
|
|
89
|
+
return None
|
|
90
|
+
buf += chunk
|
|
91
|
+
return buf
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _ws_read_frame(sock):
|
|
95
|
+
"""Read one WebSocket frame from sock.
|
|
96
|
+
|
|
97
|
+
Returns (opcode, payload) or None on EOF.
|
|
98
|
+
Raises TimeoutError on socket timeout, OSError on other errors.
|
|
99
|
+
"""
|
|
100
|
+
header = _ws_recv_exact(sock, 2)
|
|
101
|
+
if header is None:
|
|
102
|
+
return None
|
|
103
|
+
b0, b1 = header[0], header[1]
|
|
104
|
+
opcode = b0 & 0x0F
|
|
105
|
+
masked = bool(b1 & 0x80)
|
|
106
|
+
length = b1 & 0x7F
|
|
107
|
+
if length == 126:
|
|
108
|
+
ext = _ws_recv_exact(sock, 2)
|
|
109
|
+
if ext is None:
|
|
110
|
+
return None
|
|
111
|
+
length = struct.unpack("!H", ext)[0]
|
|
112
|
+
elif length == 127:
|
|
113
|
+
ext = _ws_recv_exact(sock, 8)
|
|
114
|
+
if ext is None:
|
|
115
|
+
return None
|
|
116
|
+
length = struct.unpack("!Q", ext)[0]
|
|
117
|
+
if masked:
|
|
118
|
+
mask = _ws_recv_exact(sock, 4)
|
|
119
|
+
if mask is None:
|
|
120
|
+
return None
|
|
121
|
+
raw = _ws_recv_exact(sock, length) if length else b""
|
|
122
|
+
if raw is None:
|
|
123
|
+
return None
|
|
124
|
+
data = bytearray(raw)
|
|
125
|
+
for i in range(len(data)):
|
|
126
|
+
data[i] ^= mask[i % 4]
|
|
127
|
+
return opcode, bytes(data)
|
|
128
|
+
raw = _ws_recv_exact(sock, length) if length else b""
|
|
129
|
+
if raw is None:
|
|
130
|
+
return None
|
|
131
|
+
return opcode, raw
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
SHUTDOWN_STATE_FILE = "shutdown_state.json"
|
|
135
|
+
|
|
136
|
+
# ── Component health tracking ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ComponentHealth:
|
|
140
|
+
"""Health record for a single daemon subsystem component.
|
|
141
|
+
|
|
142
|
+
Tracks status, heartbeat timestamps, and restart history in a thread-safe way.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: Component identifier (e.g. "poll", "consciousness").
|
|
146
|
+
auto_restart: Whether the watchdog should auto-restart this component.
|
|
147
|
+
heartbeat_timeout: Seconds without a heartbeat before marking dead.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
name: str,
|
|
153
|
+
*,
|
|
154
|
+
auto_restart: bool = False,
|
|
155
|
+
heartbeat_timeout: int = 120,
|
|
156
|
+
):
|
|
157
|
+
self.name = name
|
|
158
|
+
self.auto_restart = auto_restart
|
|
159
|
+
self.heartbeat_timeout = heartbeat_timeout
|
|
160
|
+
self.status: str = "pending"
|
|
161
|
+
self.started_at: Optional[datetime] = None
|
|
162
|
+
self.last_heartbeat: Optional[datetime] = None
|
|
163
|
+
self.restart_count: int = 0
|
|
164
|
+
self.last_error: Optional[str] = None
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
|
|
167
|
+
def mark_started(self) -> None:
|
|
168
|
+
"""Transition to alive and record start time."""
|
|
169
|
+
with self._lock:
|
|
170
|
+
self.status = "alive"
|
|
171
|
+
now = datetime.now(timezone.utc)
|
|
172
|
+
self.started_at = now
|
|
173
|
+
self.last_heartbeat = now
|
|
174
|
+
|
|
175
|
+
def pulse(self) -> None:
|
|
176
|
+
"""Record a heartbeat — component is alive and working."""
|
|
177
|
+
with self._lock:
|
|
178
|
+
self.last_heartbeat = datetime.now(timezone.utc)
|
|
179
|
+
if self.status != "alive":
|
|
180
|
+
self.status = "alive"
|
|
181
|
+
|
|
182
|
+
def mark_dead(self, error: str = "") -> None:
|
|
183
|
+
"""Transition to dead, optionally recording the error."""
|
|
184
|
+
with self._lock:
|
|
185
|
+
self.status = "dead"
|
|
186
|
+
if error:
|
|
187
|
+
self.last_error = error
|
|
188
|
+
|
|
189
|
+
def mark_restarting(self) -> None:
|
|
190
|
+
"""Transition to restarting and increment the restart counter."""
|
|
191
|
+
with self._lock:
|
|
192
|
+
self.status = "restarting"
|
|
193
|
+
self.restart_count += 1
|
|
194
|
+
|
|
195
|
+
def mark_disabled(self) -> None:
|
|
196
|
+
"""Mark component as permanently disabled (not started)."""
|
|
197
|
+
with self._lock:
|
|
198
|
+
self.status = "disabled"
|
|
199
|
+
|
|
200
|
+
def mark_alive(self) -> None:
|
|
201
|
+
"""Mark a passive component as alive (no auto-restart)."""
|
|
202
|
+
with self._lock:
|
|
203
|
+
self.status = "alive"
|
|
204
|
+
if not self.started_at:
|
|
205
|
+
self.started_at = datetime.now(timezone.utc)
|
|
206
|
+
self.last_heartbeat = datetime.now(timezone.utc)
|
|
207
|
+
|
|
208
|
+
def snapshot(self) -> dict:
|
|
209
|
+
"""Return a JSON-serializable snapshot of this component's health.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Dict with name, status, timestamps, restart_count, last_error.
|
|
213
|
+
"""
|
|
214
|
+
with self._lock:
|
|
215
|
+
age: Optional[int] = None
|
|
216
|
+
if self.last_heartbeat:
|
|
217
|
+
age = round(
|
|
218
|
+
(datetime.now(timezone.utc) - self.last_heartbeat).total_seconds()
|
|
219
|
+
)
|
|
220
|
+
return {
|
|
221
|
+
"name": self.name,
|
|
222
|
+
"status": self.status,
|
|
223
|
+
"auto_restart": self.auto_restart,
|
|
224
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
225
|
+
"last_heartbeat": (
|
|
226
|
+
self.last_heartbeat.isoformat() if self.last_heartbeat else None
|
|
227
|
+
),
|
|
228
|
+
"heartbeat_age_seconds": age,
|
|
229
|
+
"restart_count": self.restart_count,
|
|
230
|
+
"last_error": self.last_error,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class ComponentManager:
|
|
235
|
+
"""Tracks health and auto-restarts daemon subsystem components.
|
|
236
|
+
|
|
237
|
+
Each restartable component is registered with a loop callable. A watchdog
|
|
238
|
+
thread periodically checks liveness and restarts any component whose thread
|
|
239
|
+
has exited or whose heartbeat has timed out.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
stop_event: Shared stop event — when set the watchdog exits cleanly.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
WATCHDOG_INTERVAL = 30 # seconds between watchdog checks
|
|
246
|
+
MAX_RESTARTS = 5 # maximum auto-restart attempts per component
|
|
247
|
+
|
|
248
|
+
def __init__(self, stop_event: threading.Event):
|
|
249
|
+
self._stop_event = stop_event
|
|
250
|
+
self._health: dict[str, ComponentHealth] = {}
|
|
251
|
+
self._factories: dict[str, callable] = {}
|
|
252
|
+
self._threads: dict[str, threading.Thread] = {}
|
|
253
|
+
self._lock = threading.Lock()
|
|
254
|
+
|
|
255
|
+
def register(
|
|
256
|
+
self,
|
|
257
|
+
name: str,
|
|
258
|
+
target: callable,
|
|
259
|
+
*,
|
|
260
|
+
disabled: bool = False,
|
|
261
|
+
heartbeat_timeout: int = 120,
|
|
262
|
+
) -> ComponentHealth:
|
|
263
|
+
"""Register a restartable component loop.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
name: Unique component identifier.
|
|
267
|
+
target: Callable that implements the component's loop (runs until
|
|
268
|
+
stop_event is set).
|
|
269
|
+
disabled: If True, mark as disabled and do not start.
|
|
270
|
+
heartbeat_timeout: Seconds without a heartbeat before the watchdog
|
|
271
|
+
considers the component dead.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The ComponentHealth tracker for this component.
|
|
275
|
+
"""
|
|
276
|
+
comp = ComponentHealth(name, auto_restart=True, heartbeat_timeout=heartbeat_timeout)
|
|
277
|
+
if disabled:
|
|
278
|
+
comp.mark_disabled()
|
|
279
|
+
with self._lock:
|
|
280
|
+
self._health[name] = comp
|
|
281
|
+
self._factories[name] = target
|
|
282
|
+
return comp
|
|
283
|
+
|
|
284
|
+
def register_passive(self, name: str, *, status: str = "alive") -> ComponentHealth:
|
|
285
|
+
"""Register a non-restartable component (e.g. consciousness, scheduler).
|
|
286
|
+
|
|
287
|
+
These are tracked for status display but not auto-restarted because they
|
|
288
|
+
manage their own internal threads.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
name: Unique component identifier.
|
|
292
|
+
status: Initial status ("alive", "disabled", "dead").
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
The ComponentHealth tracker.
|
|
296
|
+
"""
|
|
297
|
+
comp = ComponentHealth(name, auto_restart=False)
|
|
298
|
+
comp.status = status
|
|
299
|
+
if status == "alive":
|
|
300
|
+
comp.started_at = datetime.now(timezone.utc)
|
|
301
|
+
comp.last_heartbeat = datetime.now(timezone.utc)
|
|
302
|
+
with self._lock:
|
|
303
|
+
self._health[name] = comp
|
|
304
|
+
return comp
|
|
305
|
+
|
|
306
|
+
def heartbeat(self, name: str) -> None:
|
|
307
|
+
"""Signal that a component is alive. Call this inside component loops.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
name: Component identifier.
|
|
311
|
+
"""
|
|
312
|
+
with self._lock:
|
|
313
|
+
comp = self._health.get(name)
|
|
314
|
+
if comp:
|
|
315
|
+
comp.pulse()
|
|
316
|
+
|
|
317
|
+
def mark_dead(self, name: str, error: str = "") -> None:
|
|
318
|
+
"""Explicitly mark a component dead.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
name: Component identifier.
|
|
322
|
+
error: Optional error message.
|
|
323
|
+
"""
|
|
324
|
+
with self._lock:
|
|
325
|
+
comp = self._health.get(name)
|
|
326
|
+
if comp:
|
|
327
|
+
comp.mark_dead(error)
|
|
328
|
+
|
|
329
|
+
def mark_alive(self, name: str) -> None:
|
|
330
|
+
"""Mark a passive component alive (e.g. after successful load).
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
name: Component identifier.
|
|
334
|
+
"""
|
|
335
|
+
with self._lock:
|
|
336
|
+
comp = self._health.get(name)
|
|
337
|
+
if comp:
|
|
338
|
+
comp.mark_alive()
|
|
339
|
+
|
|
340
|
+
def mark_disabled(self, name: str) -> None:
|
|
341
|
+
"""Mark a component as disabled.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
name: Component identifier.
|
|
345
|
+
"""
|
|
346
|
+
with self._lock:
|
|
347
|
+
comp = self._health.get(name)
|
|
348
|
+
if comp:
|
|
349
|
+
comp.mark_disabled()
|
|
350
|
+
|
|
351
|
+
def start_all(self) -> list:
|
|
352
|
+
"""Start all registered non-disabled components and the watchdog.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of started threading.Thread objects.
|
|
356
|
+
"""
|
|
357
|
+
with self._lock:
|
|
358
|
+
names = list(self._health.keys())
|
|
359
|
+
factories = dict(self._factories)
|
|
360
|
+
|
|
361
|
+
threads = []
|
|
362
|
+
for name in names:
|
|
363
|
+
with self._lock:
|
|
364
|
+
comp = self._health.get(name)
|
|
365
|
+
if comp and comp.status != "disabled" and name in factories:
|
|
366
|
+
t = self._launch(name, factories[name])
|
|
367
|
+
threads.append(t)
|
|
368
|
+
|
|
369
|
+
watchdog = threading.Thread(
|
|
370
|
+
target=self._watchdog_loop,
|
|
371
|
+
name="daemon-watchdog",
|
|
372
|
+
daemon=True,
|
|
373
|
+
)
|
|
374
|
+
watchdog.start()
|
|
375
|
+
threads.append(watchdog)
|
|
376
|
+
return threads
|
|
377
|
+
|
|
378
|
+
def _launch(self, name: str, target: callable) -> threading.Thread:
|
|
379
|
+
"""Launch a component thread, wrapping it to detect crashes.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
name: Component identifier.
|
|
383
|
+
target: Loop callable.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
The started Thread.
|
|
387
|
+
"""
|
|
388
|
+
with self._lock:
|
|
389
|
+
comp = self._health.get(name)
|
|
390
|
+
if comp:
|
|
391
|
+
comp.mark_started()
|
|
392
|
+
|
|
393
|
+
def _wrapper():
|
|
394
|
+
try:
|
|
395
|
+
target()
|
|
396
|
+
except Exception as exc:
|
|
397
|
+
logger.error("Component '%s' crashed: %s", name, exc)
|
|
398
|
+
with self._lock:
|
|
399
|
+
c = self._health.get(name)
|
|
400
|
+
if c:
|
|
401
|
+
c.mark_dead(str(exc))
|
|
402
|
+
else:
|
|
403
|
+
if not self._stop_event.is_set():
|
|
404
|
+
logger.warning("Component '%s' exited unexpectedly", name)
|
|
405
|
+
with self._lock:
|
|
406
|
+
c = self._health.get(name)
|
|
407
|
+
if c:
|
|
408
|
+
c.mark_dead("exited unexpectedly")
|
|
409
|
+
|
|
410
|
+
t = threading.Thread(target=_wrapper, name=f"daemon-{name}", daemon=True)
|
|
411
|
+
t.start()
|
|
412
|
+
with self._lock:
|
|
413
|
+
self._threads[name] = t
|
|
414
|
+
return t
|
|
415
|
+
|
|
416
|
+
def _check_components(self) -> None:
|
|
417
|
+
"""Inspect all auto-restart components and restart any that are dead.
|
|
418
|
+
|
|
419
|
+
Called by the watchdog loop and also usable directly in tests.
|
|
420
|
+
"""
|
|
421
|
+
with self._lock:
|
|
422
|
+
comps = dict(self._health)
|
|
423
|
+
factories = dict(self._factories)
|
|
424
|
+
threads = dict(self._threads)
|
|
425
|
+
|
|
426
|
+
for name, comp in comps.items():
|
|
427
|
+
if not comp.auto_restart:
|
|
428
|
+
continue
|
|
429
|
+
if comp.status in ("disabled", "restarting"):
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
t = threads.get(name)
|
|
433
|
+
needs_restart = False
|
|
434
|
+
|
|
435
|
+
if comp.status == "dead":
|
|
436
|
+
needs_restart = True
|
|
437
|
+
elif t is not None and not t.is_alive() and comp.status == "alive":
|
|
438
|
+
logger.warning("Component '%s' thread exited", name)
|
|
439
|
+
comp.mark_dead("thread exited")
|
|
440
|
+
needs_restart = True
|
|
441
|
+
elif comp.last_heartbeat:
|
|
442
|
+
age = (
|
|
443
|
+
datetime.now(timezone.utc) - comp.last_heartbeat
|
|
444
|
+
).total_seconds()
|
|
445
|
+
if age > comp.heartbeat_timeout:
|
|
446
|
+
logger.warning(
|
|
447
|
+
"Component '%s' heartbeat timeout (%.0fs old)", name, age
|
|
448
|
+
)
|
|
449
|
+
comp.mark_dead("heartbeat timeout")
|
|
450
|
+
needs_restart = True
|
|
451
|
+
|
|
452
|
+
if not needs_restart:
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
if comp.restart_count >= self.MAX_RESTARTS:
|
|
456
|
+
logger.error(
|
|
457
|
+
"Component '%s' exceeded max restarts (%d) — giving up",
|
|
458
|
+
name,
|
|
459
|
+
self.MAX_RESTARTS,
|
|
460
|
+
)
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
target = factories.get(name)
|
|
464
|
+
if target:
|
|
465
|
+
logger.warning(
|
|
466
|
+
"Watchdog auto-restarting '%s' (attempt %d/%d)",
|
|
467
|
+
name,
|
|
468
|
+
comp.restart_count + 1,
|
|
469
|
+
self.MAX_RESTARTS,
|
|
470
|
+
)
|
|
471
|
+
comp.mark_restarting()
|
|
472
|
+
self._launch(name, target)
|
|
473
|
+
|
|
474
|
+
def _watchdog_loop(self) -> None:
|
|
475
|
+
"""Periodically check component health and restart dead components."""
|
|
476
|
+
while not self._stop_event.is_set():
|
|
477
|
+
self._stop_event.wait(timeout=self.WATCHDOG_INTERVAL)
|
|
478
|
+
if self._stop_event.is_set():
|
|
479
|
+
break
|
|
480
|
+
self._check_components()
|
|
481
|
+
|
|
482
|
+
def snapshot(self) -> dict:
|
|
483
|
+
"""Return a serializable snapshot of all component health records.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Dict mapping component name → health snapshot dict.
|
|
487
|
+
"""
|
|
488
|
+
with self._lock:
|
|
489
|
+
comps = dict(self._health)
|
|
490
|
+
return {name: comp.snapshot() for name, comp in comps.items()}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class DaemonConfig:
|
|
494
|
+
"""Configuration for the daemon process.
|
|
495
|
+
|
|
496
|
+
Attributes:
|
|
497
|
+
home: Per-agent home directory.
|
|
498
|
+
shared_root: Shared root for coordination, heartbeats, peers.
|
|
499
|
+
poll_interval: Seconds between inbox polls.
|
|
500
|
+
sync_interval: Seconds between vault sync pushes.
|
|
501
|
+
health_interval: Seconds between transport health checks.
|
|
502
|
+
port: HTTP API port for local queries.
|
|
503
|
+
log_file: Path for daemon log output.
|
|
504
|
+
consciousness_enabled: Whether to start the consciousness loop.
|
|
505
|
+
consciousness_config_path: Optional path to consciousness config.
|
|
506
|
+
tls_enabled: When True the API server uses HTTPS (set via
|
|
507
|
+
``SKCAPSTONE_TLS=true``). A self-signed certificate is
|
|
508
|
+
auto-generated under ``~/.skcapstone/tls/`` on first start.
|
|
509
|
+
tls_dir: Directory for TLS certificate and key files.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
def __init__(
|
|
513
|
+
self,
|
|
514
|
+
home: Optional[Path] = None,
|
|
515
|
+
shared_root: Optional[Path] = None,
|
|
516
|
+
poll_interval: int = 10,
|
|
517
|
+
sync_interval: int = 300,
|
|
518
|
+
health_interval: int = 60,
|
|
519
|
+
port: int = DEFAULT_PORT,
|
|
520
|
+
consciousness_enabled: bool = True,
|
|
521
|
+
consciousness_config_path: Optional[Path] = None,
|
|
522
|
+
tls_enabled: Optional[bool] = None,
|
|
523
|
+
tls_dir: Optional[Path] = None,
|
|
524
|
+
):
|
|
525
|
+
self.home = (home or Path(AGENT_HOME)).expanduser()
|
|
526
|
+
self.shared_root = (shared_root or Path(SHARED_ROOT)).expanduser()
|
|
527
|
+
self.poll_interval = poll_interval
|
|
528
|
+
self.sync_interval = sync_interval
|
|
529
|
+
self.health_interval = health_interval
|
|
530
|
+
self.port = port
|
|
531
|
+
self.consciousness_enabled = consciousness_enabled
|
|
532
|
+
self.consciousness_config_path = consciousness_config_path
|
|
533
|
+
|
|
534
|
+
# TLS: env var SKCAPSTONE_TLS=true overrides the constructor arg
|
|
535
|
+
if tls_enabled is None:
|
|
536
|
+
tls_enabled = os.environ.get("SKCAPSTONE_TLS", "").lower() in ("1", "true", "yes")
|
|
537
|
+
self.tls_enabled: bool = tls_enabled
|
|
538
|
+
self.tls_dir: Path = (tls_dir or self.home / "tls").expanduser()
|
|
539
|
+
|
|
540
|
+
log_dir = self.home / LOG_DIR
|
|
541
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
542
|
+
self.log_file = log_dir / "daemon.log"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class DaemonState:
|
|
546
|
+
"""Thread-safe mutable daemon state.
|
|
547
|
+
|
|
548
|
+
Stores the latest results from polling, health checks,
|
|
549
|
+
and sync operations. All access is lock-protected.
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
def __init__(self):
|
|
553
|
+
self._lock = threading.Lock()
|
|
554
|
+
self.started_at: Optional[datetime] = None
|
|
555
|
+
self.last_poll: Optional[datetime] = None
|
|
556
|
+
self.last_sync: Optional[datetime] = None
|
|
557
|
+
self.last_health: Optional[datetime] = None
|
|
558
|
+
self.messages_received: int = 0
|
|
559
|
+
self.syncs_completed: int = 0
|
|
560
|
+
self.health_reports: dict = {}
|
|
561
|
+
self.errors: list[str] = []
|
|
562
|
+
self.running: bool = False
|
|
563
|
+
self.consciousness_stats: dict = {}
|
|
564
|
+
self.self_healing_report: dict = {}
|
|
565
|
+
self.healing_history: list[dict] = []
|
|
566
|
+
self.inflight_messages: dict[str, dict] = {}
|
|
567
|
+
self.sync_pipeline_status: dict = {}
|
|
568
|
+
|
|
569
|
+
def snapshot(self) -> dict:
|
|
570
|
+
"""Return a serializable snapshot of current state.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Dict with all state fields, safe for JSON serialization.
|
|
574
|
+
"""
|
|
575
|
+
with self._lock:
|
|
576
|
+
return {
|
|
577
|
+
"running": self.running,
|
|
578
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
579
|
+
"uptime_seconds": (
|
|
580
|
+
(datetime.now(timezone.utc) - self.started_at).total_seconds()
|
|
581
|
+
if self.started_at
|
|
582
|
+
else 0
|
|
583
|
+
),
|
|
584
|
+
"last_poll": self.last_poll.isoformat() if self.last_poll else None,
|
|
585
|
+
"last_sync": self.last_sync.isoformat() if self.last_sync else None,
|
|
586
|
+
"last_health": self.last_health.isoformat() if self.last_health else None,
|
|
587
|
+
"messages_received": self.messages_received,
|
|
588
|
+
"syncs_completed": self.syncs_completed,
|
|
589
|
+
"transport_health": self.health_reports,
|
|
590
|
+
"consciousness": self.consciousness_stats,
|
|
591
|
+
"self_healing": self.self_healing_report,
|
|
592
|
+
"self_healing_history": list(self.healing_history[-5:]),
|
|
593
|
+
"sync_pipeline": self.sync_pipeline_status,
|
|
594
|
+
"recent_errors": self.errors[-10:],
|
|
595
|
+
"inflight_count": len(self.inflight_messages),
|
|
596
|
+
"pid": os.getpid(),
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
def record_sync_pipeline(self, status: dict) -> None:
|
|
600
|
+
"""Record a sync pipeline status snapshot.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
status: Dict from :func:`skcapstone.sync_engine.get_sync_pipeline_status`.
|
|
604
|
+
"""
|
|
605
|
+
with self._lock:
|
|
606
|
+
self.sync_pipeline_status = status
|
|
607
|
+
|
|
608
|
+
def record_poll(self, count: int) -> None:
|
|
609
|
+
"""Record an inbox poll result."""
|
|
610
|
+
with self._lock:
|
|
611
|
+
self.last_poll = datetime.now(timezone.utc)
|
|
612
|
+
self.messages_received += count
|
|
613
|
+
|
|
614
|
+
def record_sync(self) -> None:
|
|
615
|
+
"""Record a successful sync push."""
|
|
616
|
+
with self._lock:
|
|
617
|
+
self.last_sync = datetime.now(timezone.utc)
|
|
618
|
+
self.syncs_completed += 1
|
|
619
|
+
|
|
620
|
+
def record_health(self, report: dict) -> None:
|
|
621
|
+
"""Record transport health check results."""
|
|
622
|
+
with self._lock:
|
|
623
|
+
self.last_health = datetime.now(timezone.utc)
|
|
624
|
+
self.health_reports = report
|
|
625
|
+
|
|
626
|
+
def record_error(self, error: str) -> None:
|
|
627
|
+
"""Record an error, keeping only the last 50."""
|
|
628
|
+
with self._lock:
|
|
629
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
630
|
+
self.errors.append(f"[{ts}] {error}")
|
|
631
|
+
if len(self.errors) > 50:
|
|
632
|
+
self.errors = self.errors[-50:]
|
|
633
|
+
|
|
634
|
+
def record_healing_run(self, report: dict) -> None:
|
|
635
|
+
"""Record a self-healing run result, keeping the last 20 entries.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
report: Healing report dict from SelfHealingDoctor.diagnose_and_heal().
|
|
639
|
+
"""
|
|
640
|
+
with self._lock:
|
|
641
|
+
self.self_healing_report = report
|
|
642
|
+
self.healing_history.append(report)
|
|
643
|
+
if len(self.healing_history) > 20:
|
|
644
|
+
self.healing_history = self.healing_history[-20:]
|
|
645
|
+
|
|
646
|
+
def add_inflight(self, msg_id: str, data: dict) -> None:
|
|
647
|
+
"""Mark a message as in-flight (being processed).
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
msg_id: Unique message identifier.
|
|
651
|
+
data: Serializable envelope metadata for persistence.
|
|
652
|
+
"""
|
|
653
|
+
with self._lock:
|
|
654
|
+
self.inflight_messages[msg_id] = data
|
|
655
|
+
|
|
656
|
+
def remove_inflight(self, msg_id: str) -> None:
|
|
657
|
+
"""Remove a message from the in-flight set (processing complete).
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
msg_id: Unique message identifier.
|
|
661
|
+
"""
|
|
662
|
+
with self._lock:
|
|
663
|
+
self.inflight_messages.pop(msg_id, None)
|
|
664
|
+
|
|
665
|
+
def get_inflight(self) -> list[dict]:
|
|
666
|
+
"""Return a snapshot of all currently in-flight message data."""
|
|
667
|
+
with self._lock:
|
|
668
|
+
return list(self.inflight_messages.values())
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
class DaemonService:
|
|
672
|
+
"""The sovereign daemon process.
|
|
673
|
+
|
|
674
|
+
Manages background threads for inbox polling, vault sync,
|
|
675
|
+
and transport health monitoring. Exposes an HTTP API for
|
|
676
|
+
local status queries.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
config: Daemon configuration.
|
|
680
|
+
"""
|
|
681
|
+
|
|
682
|
+
def __init__(self, config: DaemonConfig):
|
|
683
|
+
self.config = config
|
|
684
|
+
self.state = DaemonState()
|
|
685
|
+
self._stop_event = threading.Event()
|
|
686
|
+
self._threads: list[threading.Thread] = []
|
|
687
|
+
self._server: Optional[HTTPServer] = None
|
|
688
|
+
self._skcomm = None
|
|
689
|
+
self._runtime = None
|
|
690
|
+
self._consciousness = None
|
|
691
|
+
self._healer = None
|
|
692
|
+
self._beacon = None
|
|
693
|
+
self._scheduler = None
|
|
694
|
+
# WebSocket clients: set of raw sockets for connected /ws clients
|
|
695
|
+
self._ws_clients: set = set()
|
|
696
|
+
self._ws_lock = threading.Lock()
|
|
697
|
+
# Component health manager — populated in start()
|
|
698
|
+
self._component_mgr = ComponentManager(self._stop_event)
|
|
699
|
+
|
|
700
|
+
def start(self) -> None:
|
|
701
|
+
"""Start the daemon and all background workers.
|
|
702
|
+
|
|
703
|
+
Writes a PID file, sets up signal handlers, and starts
|
|
704
|
+
polling, sync, health, and HTTP threads.
|
|
705
|
+
"""
|
|
706
|
+
self._write_pid()
|
|
707
|
+
self._setup_logging()
|
|
708
|
+
self._setup_signals()
|
|
709
|
+
|
|
710
|
+
self.state.running = True
|
|
711
|
+
self.state.started_at = datetime.now(timezone.utc)
|
|
712
|
+
|
|
713
|
+
logger.info(
|
|
714
|
+
"Daemon starting — home=%s port=%d poll=%ds sync=%ds",
|
|
715
|
+
self.config.home,
|
|
716
|
+
self.config.port,
|
|
717
|
+
self.config.poll_interval,
|
|
718
|
+
self.config.sync_interval,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
self._run_preflight()
|
|
722
|
+
self._load_components()
|
|
723
|
+
self._load_startup_state()
|
|
724
|
+
|
|
725
|
+
# ── Register restartable core loops with the component manager ─────────
|
|
726
|
+
poll_timeout = max(self.config.poll_interval * 3 + 30, 60)
|
|
727
|
+
health_timeout = max(self.config.health_interval * 3 + 30, 60)
|
|
728
|
+
sync_timeout = max(self.config.sync_interval * 3 + 30, 120)
|
|
729
|
+
|
|
730
|
+
self._component_mgr.register("poll", self._poll_loop, heartbeat_timeout=poll_timeout)
|
|
731
|
+
self._component_mgr.register("health", self._health_loop, heartbeat_timeout=health_timeout)
|
|
732
|
+
self._component_mgr.register("sync", self._sync_loop, heartbeat_timeout=sync_timeout)
|
|
733
|
+
self._component_mgr.register(
|
|
734
|
+
"housekeeping", self._housekeeping_loop, heartbeat_timeout=7230
|
|
735
|
+
)
|
|
736
|
+
self._component_mgr.register(
|
|
737
|
+
"healing",
|
|
738
|
+
self._healing_loop,
|
|
739
|
+
disabled=not bool(self._healer),
|
|
740
|
+
heartbeat_timeout=930,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# ── Register passive components (managed externally) ──────────────────
|
|
744
|
+
self._component_mgr.register_passive(
|
|
745
|
+
"consciousness",
|
|
746
|
+
status="alive" if self._consciousness else "disabled",
|
|
747
|
+
)
|
|
748
|
+
self._component_mgr.register_passive(
|
|
749
|
+
"scheduler",
|
|
750
|
+
status="alive" if self._scheduler else "disabled",
|
|
751
|
+
)
|
|
752
|
+
self._component_mgr.register_passive(
|
|
753
|
+
"heartbeat",
|
|
754
|
+
status="alive" if self._beacon else "disabled",
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
# Start all registered components (core loops + watchdog)
|
|
758
|
+
component_threads = self._component_mgr.start_all()
|
|
759
|
+
self._threads.extend(component_threads)
|
|
760
|
+
|
|
761
|
+
# Start consciousness loop threads (manages own threads internally)
|
|
762
|
+
if self._consciousness:
|
|
763
|
+
consciousness_threads = self._consciousness.start()
|
|
764
|
+
self._threads.extend(consciousness_threads)
|
|
765
|
+
|
|
766
|
+
# Start task scheduler (manages its own thread internally)
|
|
767
|
+
if self._scheduler:
|
|
768
|
+
scheduler_thread = self._scheduler.start()
|
|
769
|
+
self._threads.append(scheduler_thread)
|
|
770
|
+
|
|
771
|
+
self._start_api_server()
|
|
772
|
+
|
|
773
|
+
logger.info("Daemon started — PID %d", os.getpid())
|
|
774
|
+
|
|
775
|
+
def stop(self) -> None:
|
|
776
|
+
"""Gracefully stop the daemon and all workers."""
|
|
777
|
+
logger.info("Daemon stopping...")
|
|
778
|
+
self._stop_event.set()
|
|
779
|
+
self.state.running = False
|
|
780
|
+
|
|
781
|
+
if self._consciousness:
|
|
782
|
+
try:
|
|
783
|
+
self._consciousness.stop()
|
|
784
|
+
except Exception as exc:
|
|
785
|
+
logger.warning("Consciousness stop error: %s", exc)
|
|
786
|
+
|
|
787
|
+
if self._server:
|
|
788
|
+
try:
|
|
789
|
+
self._server.shutdown()
|
|
790
|
+
except Exception as exc:
|
|
791
|
+
logger.warning("API server shutdown error: %s", exc)
|
|
792
|
+
|
|
793
|
+
for t in self._threads:
|
|
794
|
+
t.join(timeout=5)
|
|
795
|
+
|
|
796
|
+
self._save_shutdown_state()
|
|
797
|
+
self._remove_pid()
|
|
798
|
+
logger.info("Daemon stopped.")
|
|
799
|
+
|
|
800
|
+
def run_forever(self) -> None:
|
|
801
|
+
"""Block until stop is signaled.
|
|
802
|
+
|
|
803
|
+
Typically called after start() in the main process.
|
|
804
|
+
"""
|
|
805
|
+
try:
|
|
806
|
+
while not self._stop_event.is_set():
|
|
807
|
+
self._stop_event.wait(timeout=1)
|
|
808
|
+
except KeyboardInterrupt:
|
|
809
|
+
pass
|
|
810
|
+
finally:
|
|
811
|
+
self.stop()
|
|
812
|
+
|
|
813
|
+
def _run_preflight(self) -> None:
|
|
814
|
+
"""Run preflight checks before starting the daemon.
|
|
815
|
+
|
|
816
|
+
Logs warnings for non-critical issues and aborts with SystemExit
|
|
817
|
+
if any critical check fails.
|
|
818
|
+
"""
|
|
819
|
+
try:
|
|
820
|
+
from .preflight import PreflightChecker
|
|
821
|
+
except ImportError:
|
|
822
|
+
logger.warning("PreflightChecker not available — skipping preflight")
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
checker = PreflightChecker(home=self.config.home)
|
|
826
|
+
summary = checker.run_all()
|
|
827
|
+
|
|
828
|
+
for check in summary["checks"]:
|
|
829
|
+
name = check["name"]
|
|
830
|
+
status = check["status"]
|
|
831
|
+
msg = check["message"]
|
|
832
|
+
if status == "ok":
|
|
833
|
+
logger.info("preflight [%s] OK — %s", name, msg)
|
|
834
|
+
elif status == "warn":
|
|
835
|
+
logger.warning("preflight [%s] WARN — %s", name, msg)
|
|
836
|
+
else:
|
|
837
|
+
logger.error("preflight [%s] FAIL — %s", name, msg)
|
|
838
|
+
|
|
839
|
+
if not summary["ok"]:
|
|
840
|
+
failed = [c for c in summary["checks"] if c["status"] == "fail" and c["critical"]]
|
|
841
|
+
msgs = "; ".join(c["message"] for c in failed)
|
|
842
|
+
logger.error("Preflight FAILED — aborting daemon startup: %s", msgs)
|
|
843
|
+
raise SystemExit(f"Daemon preflight failed: {msgs}")
|
|
844
|
+
|
|
845
|
+
if summary["warnings"] or summary["failures"]:
|
|
846
|
+
logger.warning(
|
|
847
|
+
"Preflight complete — %d warning(s), %d non-critical failure(s)",
|
|
848
|
+
summary["warnings"],
|
|
849
|
+
summary["failures"],
|
|
850
|
+
)
|
|
851
|
+
else:
|
|
852
|
+
logger.info("Preflight complete — all checks passed")
|
|
853
|
+
|
|
854
|
+
def _load_components(self) -> None:
|
|
855
|
+
"""Attempt to load SKComm, AgentRuntime, and ConsciousnessLoop."""
|
|
856
|
+
try:
|
|
857
|
+
from skcomm.core import SKComm
|
|
858
|
+
self._skcomm = SKComm.from_config()
|
|
859
|
+
logger.info("SKComm loaded — %d transports", len(self._skcomm.router.transports))
|
|
860
|
+
except ImportError:
|
|
861
|
+
logger.warning("SKComm not installed — inbox polling disabled")
|
|
862
|
+
except Exception as exc:
|
|
863
|
+
logger.warning("SKComm failed to load: %s", exc)
|
|
864
|
+
self.state.record_error(f"SKComm load: {exc}")
|
|
865
|
+
|
|
866
|
+
try:
|
|
867
|
+
from .runtime import get_runtime
|
|
868
|
+
self._runtime = get_runtime(self.config.home)
|
|
869
|
+
logger.info("Runtime loaded — agent '%s'", self._runtime.manifest.name)
|
|
870
|
+
except Exception as exc:
|
|
871
|
+
logger.warning("Runtime failed to load: %s", exc)
|
|
872
|
+
self.state.record_error(f"Runtime load: {exc}")
|
|
873
|
+
|
|
874
|
+
try:
|
|
875
|
+
from .heartbeat import HeartbeatBeacon
|
|
876
|
+
agent_name = self._runtime.manifest.name if self._runtime else "anonymous"
|
|
877
|
+
self._beacon = HeartbeatBeacon(self.config.home, agent_name)
|
|
878
|
+
logger.info("HeartbeatBeacon initialized for '%s'", agent_name)
|
|
879
|
+
except Exception as exc:
|
|
880
|
+
logger.warning("HeartbeatBeacon failed to init: %s", exc)
|
|
881
|
+
self.state.record_error(f"Heartbeat init: {exc}")
|
|
882
|
+
|
|
883
|
+
# Load consciousness loop
|
|
884
|
+
if self.config.consciousness_enabled:
|
|
885
|
+
try:
|
|
886
|
+
from .consciousness_config import load_consciousness_config
|
|
887
|
+
from .consciousness_loop import ConsciousnessLoop
|
|
888
|
+
|
|
889
|
+
cli_disabled = not self.config.consciousness_enabled
|
|
890
|
+
c_config = load_consciousness_config(
|
|
891
|
+
self.config.home,
|
|
892
|
+
cli_disabled=cli_disabled,
|
|
893
|
+
config_path=self.config.consciousness_config_path,
|
|
894
|
+
)
|
|
895
|
+
if c_config.enabled:
|
|
896
|
+
self._consciousness = ConsciousnessLoop(
|
|
897
|
+
c_config, self.state,
|
|
898
|
+
home=self.config.home,
|
|
899
|
+
shared_root=self.config.shared_root,
|
|
900
|
+
)
|
|
901
|
+
if self._skcomm:
|
|
902
|
+
self._consciousness.set_skcomm(self._skcomm)
|
|
903
|
+
logger.info("Consciousness loop loaded")
|
|
904
|
+
|
|
905
|
+
# Preload Ollama model into RAM so first real message is fast
|
|
906
|
+
def _ollama_warmup():
|
|
907
|
+
try:
|
|
908
|
+
from skseed.llm import ollama_callback
|
|
909
|
+
cb = ollama_callback(model="llama3.2")
|
|
910
|
+
cb("warmup")
|
|
911
|
+
logger.info("Ollama warmup complete — llama3.2 loaded")
|
|
912
|
+
except Exception as exc:
|
|
913
|
+
logger.debug("Ollama warmup skipped: %s", exc)
|
|
914
|
+
|
|
915
|
+
threading.Thread(
|
|
916
|
+
target=_ollama_warmup,
|
|
917
|
+
name="daemon-ollama-warmup",
|
|
918
|
+
daemon=True,
|
|
919
|
+
).start()
|
|
920
|
+
else:
|
|
921
|
+
logger.info("Consciousness loop disabled by config")
|
|
922
|
+
except Exception as exc:
|
|
923
|
+
logger.warning("Consciousness loop failed to load: %s", exc)
|
|
924
|
+
self.state.record_error(f"Consciousness load: {exc}")
|
|
925
|
+
|
|
926
|
+
# Load self-healing doctor
|
|
927
|
+
try:
|
|
928
|
+
from .self_healing import SelfHealingDoctor
|
|
929
|
+
self._healer = SelfHealingDoctor(
|
|
930
|
+
self.config.home, consciousness_loop=self._consciousness,
|
|
931
|
+
)
|
|
932
|
+
logger.info("Self-healing doctor loaded")
|
|
933
|
+
except Exception as exc:
|
|
934
|
+
logger.warning("Self-healing doctor failed to load: %s", exc)
|
|
935
|
+
self.state.record_error(f"Self-healing load: {exc}")
|
|
936
|
+
|
|
937
|
+
# Build task scheduler (beacon + consciousness must be ready first)
|
|
938
|
+
try:
|
|
939
|
+
from .scheduled_tasks import build_scheduler
|
|
940
|
+
|
|
941
|
+
# Get sync_watcher from consciousness loop if available
|
|
942
|
+
_sync_watcher = getattr(self._consciousness, "_sync_watcher", None)
|
|
943
|
+
self._scheduler = build_scheduler(
|
|
944
|
+
home=self.config.home,
|
|
945
|
+
stop_event=self._stop_event,
|
|
946
|
+
consciousness_loop=self._consciousness,
|
|
947
|
+
beacon=self._beacon,
|
|
948
|
+
sync_watcher=_sync_watcher,
|
|
949
|
+
)
|
|
950
|
+
logger.info("Task scheduler built — %d task(s)", len(self._scheduler._tasks))
|
|
951
|
+
except Exception as exc:
|
|
952
|
+
logger.warning("Task scheduler failed to build: %s", exc)
|
|
953
|
+
self.state.record_error(f"Scheduler build: {exc}")
|
|
954
|
+
|
|
955
|
+
def _poll_loop(self) -> None:
|
|
956
|
+
"""Continuously poll SKComm inbox for new messages."""
|
|
957
|
+
while not self._stop_event.is_set():
|
|
958
|
+
self._component_mgr.heartbeat("poll")
|
|
959
|
+
if self._skcomm:
|
|
960
|
+
try:
|
|
961
|
+
envelopes = self._skcomm.receive()
|
|
962
|
+
count = len(envelopes)
|
|
963
|
+
self.state.record_poll(count)
|
|
964
|
+
if count > 0:
|
|
965
|
+
logger.info("Received %d message(s)", count)
|
|
966
|
+
self._process_messages(envelopes)
|
|
967
|
+
except Exception as exc:
|
|
968
|
+
logger.error("Poll error: %s", exc)
|
|
969
|
+
self.state.record_error(f"Poll: {exc}")
|
|
970
|
+
else:
|
|
971
|
+
self.state.record_poll(0)
|
|
972
|
+
|
|
973
|
+
self._stop_event.wait(timeout=self.config.poll_interval)
|
|
974
|
+
|
|
975
|
+
def _health_loop(self) -> None:
|
|
976
|
+
"""Periodically check transport health."""
|
|
977
|
+
while not self._stop_event.is_set():
|
|
978
|
+
self._component_mgr.heartbeat("health")
|
|
979
|
+
if self._skcomm:
|
|
980
|
+
try:
|
|
981
|
+
report = self._skcomm.status()
|
|
982
|
+
transports = report.get("transports", {})
|
|
983
|
+
serializable = {}
|
|
984
|
+
for name, health in transports.items():
|
|
985
|
+
if hasattr(health, "model_dump"):
|
|
986
|
+
serializable[name] = health.model_dump()
|
|
987
|
+
elif isinstance(health, dict):
|
|
988
|
+
serializable[name] = health
|
|
989
|
+
else:
|
|
990
|
+
serializable[name] = str(health)
|
|
991
|
+
self.state.record_health(serializable)
|
|
992
|
+
except Exception as exc:
|
|
993
|
+
logger.error("Health check error: %s", exc)
|
|
994
|
+
self.state.record_error(f"Health: {exc}")
|
|
995
|
+
|
|
996
|
+
if self._beacon:
|
|
997
|
+
try:
|
|
998
|
+
c_stats = self._consciousness.stats if self._consciousness else {}
|
|
999
|
+
conv_dir = self.config.shared_root / "conversations"
|
|
1000
|
+
active_convs = len(list(conv_dir.glob("*.json"))) if conv_dir.exists() else 0
|
|
1001
|
+
self._beacon.pulse(
|
|
1002
|
+
consciousness_active=bool(self._consciousness),
|
|
1003
|
+
active_conversations=active_convs,
|
|
1004
|
+
messages_processed_24h=c_stats.get("messages_processed_24h", 0),
|
|
1005
|
+
)
|
|
1006
|
+
_activity.push("heartbeat.published", {
|
|
1007
|
+
"status": "alive",
|
|
1008
|
+
"consciousness_active": bool(self._consciousness),
|
|
1009
|
+
"active_conversations": active_convs,
|
|
1010
|
+
"messages_processed_24h": c_stats.get("messages_processed_24h", 0),
|
|
1011
|
+
})
|
|
1012
|
+
except Exception as exc:
|
|
1013
|
+
logger.warning("Heartbeat pulse failed: %s", exc)
|
|
1014
|
+
|
|
1015
|
+
# Sync pipeline status — inbox/outbox file counts and path alignment
|
|
1016
|
+
try:
|
|
1017
|
+
from .sync_engine import get_sync_pipeline_status
|
|
1018
|
+
sync_status = get_sync_pipeline_status(self.config.shared_root)
|
|
1019
|
+
self.state.record_sync_pipeline(sync_status)
|
|
1020
|
+
if sync_status.get("inbox_files", 0) > 0:
|
|
1021
|
+
logger.debug(
|
|
1022
|
+
"Sync pipeline: %d inbox file(s) pending from %s",
|
|
1023
|
+
sync_status["inbox_files"],
|
|
1024
|
+
sync_status["inbox_peers"],
|
|
1025
|
+
)
|
|
1026
|
+
except Exception as exc:
|
|
1027
|
+
logger.warning("Sync pipeline status check failed: %s", exc)
|
|
1028
|
+
|
|
1029
|
+
self._stop_event.wait(timeout=self.config.health_interval)
|
|
1030
|
+
|
|
1031
|
+
def _sync_loop(self) -> None:
|
|
1032
|
+
"""Periodically push vault sync."""
|
|
1033
|
+
while not self._stop_event.is_set():
|
|
1034
|
+
self._stop_event.wait(timeout=self.config.sync_interval)
|
|
1035
|
+
if self._stop_event.is_set():
|
|
1036
|
+
break
|
|
1037
|
+
self._component_mgr.heartbeat("sync")
|
|
1038
|
+
if self._runtime and self._runtime.is_initialized:
|
|
1039
|
+
try:
|
|
1040
|
+
from .pillars.sync import push_seed
|
|
1041
|
+
name = self._runtime.manifest.name
|
|
1042
|
+
result = push_seed(self.config.home, name, encrypt=True)
|
|
1043
|
+
if result:
|
|
1044
|
+
self.state.record_sync()
|
|
1045
|
+
logger.info("Vault sync push completed: %s", result.name)
|
|
1046
|
+
except Exception as exc:
|
|
1047
|
+
logger.error("Sync push error: %s", exc)
|
|
1048
|
+
self.state.record_error(f"Sync: {exc}")
|
|
1049
|
+
|
|
1050
|
+
def _housekeeping_loop(self) -> None:
|
|
1051
|
+
"""Periodically prune stale ACKs, envelopes, and seeds (hourly)."""
|
|
1052
|
+
while not self._stop_event.is_set():
|
|
1053
|
+
self._stop_event.wait(timeout=3600)
|
|
1054
|
+
if self._stop_event.is_set():
|
|
1055
|
+
break
|
|
1056
|
+
self._component_mgr.heartbeat("housekeeping")
|
|
1057
|
+
try:
|
|
1058
|
+
from .housekeeping import run_housekeeping
|
|
1059
|
+
|
|
1060
|
+
results = run_housekeeping(
|
|
1061
|
+
skcapstone_home=self.config.shared_root,
|
|
1062
|
+
)
|
|
1063
|
+
summary = results.get("summary", {})
|
|
1064
|
+
deleted = summary.get("total_deleted", 0)
|
|
1065
|
+
freed_mb = summary.get("total_freed_mb", 0)
|
|
1066
|
+
if deleted > 0:
|
|
1067
|
+
logger.info(
|
|
1068
|
+
"Housekeeping: pruned %d files, freed %.1f MB",
|
|
1069
|
+
deleted,
|
|
1070
|
+
freed_mb,
|
|
1071
|
+
)
|
|
1072
|
+
except Exception as exc:
|
|
1073
|
+
logger.error("Housekeeping error: %s", exc)
|
|
1074
|
+
self.state.record_error(f"Housekeeping: {exc}")
|
|
1075
|
+
|
|
1076
|
+
def _process_messages(self, envelopes: list) -> None:
|
|
1077
|
+
"""Handle received messages — delegates to consciousness loop.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
envelopes: List of received MessageEnvelope objects.
|
|
1081
|
+
"""
|
|
1082
|
+
for env in envelopes:
|
|
1083
|
+
msg_id = getattr(env, "message_id", None) or str(uuid.uuid4())
|
|
1084
|
+
try:
|
|
1085
|
+
content = env.payload.content or ""
|
|
1086
|
+
content_preview = content[:50]
|
|
1087
|
+
content_type = (
|
|
1088
|
+
env.payload.content_type.value
|
|
1089
|
+
if hasattr(env.payload.content_type, "value")
|
|
1090
|
+
else str(env.payload.content_type)
|
|
1091
|
+
)
|
|
1092
|
+
sender = getattr(env, "sender", "unknown")
|
|
1093
|
+
self.state.add_inflight(msg_id, {
|
|
1094
|
+
"message_id": msg_id,
|
|
1095
|
+
"sender": sender,
|
|
1096
|
+
"content": content,
|
|
1097
|
+
"content_type": content_type,
|
|
1098
|
+
"received_at": datetime.now(timezone.utc).isoformat(),
|
|
1099
|
+
})
|
|
1100
|
+
logger.info(
|
|
1101
|
+
"Message from %s: %s [%s]",
|
|
1102
|
+
sender,
|
|
1103
|
+
content_preview,
|
|
1104
|
+
content_type,
|
|
1105
|
+
)
|
|
1106
|
+
if self._consciousness and self._consciousness._config.enabled:
|
|
1107
|
+
self._consciousness.process_envelope(env)
|
|
1108
|
+
# Activity bus: consciousness processed event
|
|
1109
|
+
_activity.push("consciousness.processed", {
|
|
1110
|
+
"sender": sender,
|
|
1111
|
+
"content_type": content_type,
|
|
1112
|
+
"preview": content_preview,
|
|
1113
|
+
})
|
|
1114
|
+
# Stream the new message to any connected WebSocket clients
|
|
1115
|
+
self._ws_broadcast({
|
|
1116
|
+
"type": "message",
|
|
1117
|
+
"sender": sender,
|
|
1118
|
+
"content": content,
|
|
1119
|
+
"content_type": content_type,
|
|
1120
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1121
|
+
})
|
|
1122
|
+
self._journal_incoming(sender, content_preview)
|
|
1123
|
+
self.state.remove_inflight(msg_id)
|
|
1124
|
+
except Exception as exc:
|
|
1125
|
+
self.state.remove_inflight(msg_id)
|
|
1126
|
+
logger.warning("Failed to process message from %s: %s", getattr(env, "sender", "?"), exc)
|
|
1127
|
+
self.state.record_error(f"Process message: {exc}")
|
|
1128
|
+
|
|
1129
|
+
def _journal_incoming(self, sender: str, preview: str) -> None:
|
|
1130
|
+
"""Auto-journal an incoming SKComm message and store a tagged memory.
|
|
1131
|
+
|
|
1132
|
+
Writes a journal entry (title='From {sender}', moments=[preview]) and
|
|
1133
|
+
stores a short-term memory tagged 'skcomm-received'. Both operations
|
|
1134
|
+
are best-effort: failures are logged at DEBUG level and never bubble up.
|
|
1135
|
+
"""
|
|
1136
|
+
try:
|
|
1137
|
+
from skmemory.journal import Journal, JournalEntry
|
|
1138
|
+
entry = JournalEntry(
|
|
1139
|
+
title=f"From {sender}",
|
|
1140
|
+
moments=[preview] if preview else [],
|
|
1141
|
+
)
|
|
1142
|
+
Journal().write_entry(entry)
|
|
1143
|
+
logger.debug("Journal entry written for incoming message from %s", sender)
|
|
1144
|
+
except Exception as exc:
|
|
1145
|
+
logger.debug("Auto-journal write failed: %s", exc)
|
|
1146
|
+
|
|
1147
|
+
try:
|
|
1148
|
+
from .memory_engine import store as mem_store
|
|
1149
|
+
mem_store(
|
|
1150
|
+
self.config.home,
|
|
1151
|
+
f"Received message from {sender}: {preview}",
|
|
1152
|
+
tags=["skcomm-received"],
|
|
1153
|
+
source="daemon",
|
|
1154
|
+
)
|
|
1155
|
+
logger.debug("Memory stored for incoming message from %s", sender)
|
|
1156
|
+
except Exception as exc:
|
|
1157
|
+
logger.debug("Auto-journal memory store failed: %s", exc)
|
|
1158
|
+
|
|
1159
|
+
def _healing_loop(self) -> None:
|
|
1160
|
+
"""Periodically run self-healing diagnostics (every 5 min)."""
|
|
1161
|
+
while not self._stop_event.is_set():
|
|
1162
|
+
self._stop_event.wait(timeout=300)
|
|
1163
|
+
if self._stop_event.is_set():
|
|
1164
|
+
break
|
|
1165
|
+
self._component_mgr.heartbeat("healing")
|
|
1166
|
+
if self._healer:
|
|
1167
|
+
try:
|
|
1168
|
+
report = self._healer.diagnose_and_heal()
|
|
1169
|
+
self.state.record_healing_run(report)
|
|
1170
|
+
|
|
1171
|
+
checks_run = report.get("checks_run", 0)
|
|
1172
|
+
auto_fixed = report.get("auto_fixed", 0)
|
|
1173
|
+
still_broken = report.get("still_broken", 0)
|
|
1174
|
+
|
|
1175
|
+
if still_broken > 0:
|
|
1176
|
+
logger.warning(
|
|
1177
|
+
"Self-healing: %d checks, %d fixed, %d critical issue(s): %s",
|
|
1178
|
+
checks_run,
|
|
1179
|
+
auto_fixed,
|
|
1180
|
+
still_broken,
|
|
1181
|
+
report.get("escalated", []),
|
|
1182
|
+
)
|
|
1183
|
+
elif auto_fixed > 0:
|
|
1184
|
+
logger.info(
|
|
1185
|
+
"Self-healing: %d checks, %d fixed, all healthy",
|
|
1186
|
+
checks_run,
|
|
1187
|
+
auto_fixed,
|
|
1188
|
+
)
|
|
1189
|
+
else:
|
|
1190
|
+
logger.debug("Self-healing: %d checks all ok", checks_run)
|
|
1191
|
+
except Exception as exc:
|
|
1192
|
+
logger.error("Self-healing error: %s", exc)
|
|
1193
|
+
self.state.record_error(f"Self-healing: {exc}")
|
|
1194
|
+
|
|
1195
|
+
# Update consciousness stats
|
|
1196
|
+
if self._consciousness:
|
|
1197
|
+
self.state.consciousness_stats = self._consciousness.stats
|
|
1198
|
+
|
|
1199
|
+
def _ws_broadcast(self, msg: dict) -> None:
|
|
1200
|
+
"""Broadcast a JSON message to all connected WebSocket clients.
|
|
1201
|
+
|
|
1202
|
+
Dead sockets are silently removed from the client set.
|
|
1203
|
+
|
|
1204
|
+
Args:
|
|
1205
|
+
msg: JSON-serialisable dict to send as a text frame.
|
|
1206
|
+
"""
|
|
1207
|
+
with self._ws_lock:
|
|
1208
|
+
if not self._ws_clients:
|
|
1209
|
+
return
|
|
1210
|
+
clients = set(self._ws_clients)
|
|
1211
|
+
frame = _ws_encode_frame(json.dumps(msg, default=str).encode("utf-8"))
|
|
1212
|
+
dead: set = set()
|
|
1213
|
+
for sock in clients:
|
|
1214
|
+
try:
|
|
1215
|
+
sock.sendall(frame)
|
|
1216
|
+
except OSError:
|
|
1217
|
+
dead.add(sock)
|
|
1218
|
+
if dead:
|
|
1219
|
+
with self._ws_lock:
|
|
1220
|
+
self._ws_clients -= dead
|
|
1221
|
+
|
|
1222
|
+
def _start_api_server(self) -> None:
|
|
1223
|
+
"""Start the local HTTP API server in a background thread."""
|
|
1224
|
+
from .rate_limiter import RateLimiter
|
|
1225
|
+
|
|
1226
|
+
service = self
|
|
1227
|
+
state = self.state
|
|
1228
|
+
config = self.config
|
|
1229
|
+
consciousness = self._consciousness
|
|
1230
|
+
runtime = self._runtime
|
|
1231
|
+
rate_limiter = RateLimiter(requests_per_minute=100)
|
|
1232
|
+
|
|
1233
|
+
class DaemonHandler(BaseHTTPRequestHandler):
|
|
1234
|
+
"""HTTP handler for daemon status API."""
|
|
1235
|
+
|
|
1236
|
+
@staticmethod
|
|
1237
|
+
def _hb_alive(hb: dict) -> bool:
|
|
1238
|
+
"""Return True if heartbeat is within its TTL."""
|
|
1239
|
+
ts_str = hb.get("timestamp", "")
|
|
1240
|
+
ttl = hb.get("ttl_seconds", 300)
|
|
1241
|
+
if not ts_str:
|
|
1242
|
+
return False
|
|
1243
|
+
try:
|
|
1244
|
+
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
1245
|
+
return datetime.now(timezone.utc) <= ts + timedelta(seconds=ttl)
|
|
1246
|
+
except Exception:
|
|
1247
|
+
return False
|
|
1248
|
+
|
|
1249
|
+
@staticmethod
|
|
1250
|
+
def _get_system_stats() -> dict:
|
|
1251
|
+
"""Collect memory and disk usage statistics."""
|
|
1252
|
+
import shutil
|
|
1253
|
+
stats: dict = {}
|
|
1254
|
+
try:
|
|
1255
|
+
usage = shutil.disk_usage("/")
|
|
1256
|
+
stats["disk_total_gb"] = round(usage.total / (1024 ** 3), 1)
|
|
1257
|
+
stats["disk_used_gb"] = round(usage.used / (1024 ** 3), 1)
|
|
1258
|
+
stats["disk_free_gb"] = round(usage.free / (1024 ** 3), 1)
|
|
1259
|
+
except Exception:
|
|
1260
|
+
stats.update(disk_total_gb=0, disk_used_gb=0, disk_free_gb=0)
|
|
1261
|
+
try:
|
|
1262
|
+
meminfo: dict = {}
|
|
1263
|
+
with open("/proc/meminfo") as fh:
|
|
1264
|
+
for line in fh:
|
|
1265
|
+
parts = line.split()
|
|
1266
|
+
if len(parts) >= 2:
|
|
1267
|
+
meminfo[parts[0].rstrip(":")] = int(parts[1])
|
|
1268
|
+
total_kb = meminfo.get("MemTotal", 0)
|
|
1269
|
+
avail_kb = meminfo.get("MemAvailable", 0)
|
|
1270
|
+
stats["memory_total_mb"] = round(total_kb / 1024)
|
|
1271
|
+
stats["memory_used_mb"] = round((total_kb - avail_kb) / 1024)
|
|
1272
|
+
stats["memory_free_mb"] = round(avail_kb / 1024)
|
|
1273
|
+
except Exception:
|
|
1274
|
+
stats.update(memory_total_mb=0, memory_used_mb=0, memory_free_mb=0)
|
|
1275
|
+
return stats
|
|
1276
|
+
|
|
1277
|
+
def _build_dashboard_data(self) -> dict:
|
|
1278
|
+
"""Assemble all dashboard data into a single dict."""
|
|
1279
|
+
snap = state.snapshot()
|
|
1280
|
+
|
|
1281
|
+
# Agent identity — try runtime first, then identity.json
|
|
1282
|
+
agent_name = "unknown"
|
|
1283
|
+
agent_fingerprint = ""
|
|
1284
|
+
if runtime and hasattr(runtime, "manifest"):
|
|
1285
|
+
try:
|
|
1286
|
+
agent_name = runtime.manifest.name or agent_name
|
|
1287
|
+
agent_fingerprint = getattr(runtime.manifest, "fingerprint", "")
|
|
1288
|
+
except Exception:
|
|
1289
|
+
pass
|
|
1290
|
+
identity_file = config.home / "identity" / "identity.json"
|
|
1291
|
+
if identity_file.exists():
|
|
1292
|
+
try:
|
|
1293
|
+
ident = json.loads(identity_file.read_text(encoding="utf-8"))
|
|
1294
|
+
agent_name = ident.get("name", agent_name)
|
|
1295
|
+
agent_fingerprint = ident.get("fingerprint", agent_fingerprint)
|
|
1296
|
+
except Exception:
|
|
1297
|
+
pass
|
|
1298
|
+
|
|
1299
|
+
# Consciousness stats
|
|
1300
|
+
c_stats: dict = snap.get("consciousness", {})
|
|
1301
|
+
if consciousness:
|
|
1302
|
+
c_stats = consciousness.stats
|
|
1303
|
+
|
|
1304
|
+
# Recent conversations (last 5 by mtime)
|
|
1305
|
+
conversations: list = []
|
|
1306
|
+
conversations_dir = config.shared_root / "conversations"
|
|
1307
|
+
if conversations_dir.exists():
|
|
1308
|
+
try:
|
|
1309
|
+
conv_files = sorted(
|
|
1310
|
+
conversations_dir.glob("*.json"),
|
|
1311
|
+
key=lambda p: p.stat().st_mtime,
|
|
1312
|
+
reverse=True,
|
|
1313
|
+
)[:5]
|
|
1314
|
+
for cf in conv_files:
|
|
1315
|
+
try:
|
|
1316
|
+
msgs = json.loads(cf.read_text(encoding="utf-8"))
|
|
1317
|
+
if isinstance(msgs, list):
|
|
1318
|
+
conversations.append({
|
|
1319
|
+
"peer": cf.stem,
|
|
1320
|
+
"message_count": len(msgs),
|
|
1321
|
+
"last_message": msgs[-1].get("timestamp") if msgs else None,
|
|
1322
|
+
})
|
|
1323
|
+
except Exception:
|
|
1324
|
+
pass
|
|
1325
|
+
except Exception:
|
|
1326
|
+
pass
|
|
1327
|
+
|
|
1328
|
+
return {
|
|
1329
|
+
"agent": {
|
|
1330
|
+
"name": agent_name,
|
|
1331
|
+
"fingerprint": agent_fingerprint,
|
|
1332
|
+
},
|
|
1333
|
+
"daemon": {
|
|
1334
|
+
"running": snap["running"],
|
|
1335
|
+
"uptime_seconds": snap["uptime_seconds"],
|
|
1336
|
+
"pid": snap["pid"],
|
|
1337
|
+
"messages_received": snap["messages_received"],
|
|
1338
|
+
"syncs_completed": snap["syncs_completed"],
|
|
1339
|
+
},
|
|
1340
|
+
"consciousness": c_stats,
|
|
1341
|
+
"backends": snap.get("transport_health", {}),
|
|
1342
|
+
"conversations": conversations,
|
|
1343
|
+
"system": self._get_system_stats(),
|
|
1344
|
+
"recent_errors": snap.get("recent_errors", [])[-5:],
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
def _build_capstone_data(self) -> dict:
|
|
1348
|
+
"""Assemble data for the GET /dashboard page.
|
|
1349
|
+
|
|
1350
|
+
Returns pillar status, memory stats, coordination board
|
|
1351
|
+
summary + active tasks, and consciousness stats in one shot.
|
|
1352
|
+
"""
|
|
1353
|
+
# ── Agent identity ────────────────────────────────────────
|
|
1354
|
+
agent: dict = {"name": "unknown", "fingerprint": "",
|
|
1355
|
+
"consciousness": "AWAKENING",
|
|
1356
|
+
"is_conscious": False, "is_singular": False}
|
|
1357
|
+
if runtime and hasattr(runtime, "manifest"):
|
|
1358
|
+
try:
|
|
1359
|
+
m = runtime.manifest
|
|
1360
|
+
agent["name"] = m.name or agent["name"]
|
|
1361
|
+
agent["fingerprint"] = getattr(m, "fingerprint", "") or ""
|
|
1362
|
+
agent["is_conscious"] = bool(m.is_conscious)
|
|
1363
|
+
agent["is_singular"] = bool(m.is_singular)
|
|
1364
|
+
if m.is_singular:
|
|
1365
|
+
agent["consciousness"] = "SINGULAR"
|
|
1366
|
+
elif m.is_conscious:
|
|
1367
|
+
agent["consciousness"] = "CONSCIOUS"
|
|
1368
|
+
except Exception:
|
|
1369
|
+
pass
|
|
1370
|
+
identity_file = config.home / "identity" / "identity.json"
|
|
1371
|
+
if identity_file.exists():
|
|
1372
|
+
try:
|
|
1373
|
+
ident = json.loads(identity_file.read_text(encoding="utf-8"))
|
|
1374
|
+
agent["name"] = ident.get("name", agent["name"])
|
|
1375
|
+
agent["fingerprint"] = ident.get("fingerprint", agent["fingerprint"])
|
|
1376
|
+
except Exception:
|
|
1377
|
+
pass
|
|
1378
|
+
|
|
1379
|
+
# ── Pillar status ─────────────────────────────────────────
|
|
1380
|
+
pillars: dict = {}
|
|
1381
|
+
if runtime and hasattr(runtime, "manifest"):
|
|
1382
|
+
try:
|
|
1383
|
+
pillars = {
|
|
1384
|
+
k: v.value
|
|
1385
|
+
for k, v in runtime.manifest.pillar_summary.items()
|
|
1386
|
+
}
|
|
1387
|
+
except Exception:
|
|
1388
|
+
pass
|
|
1389
|
+
|
|
1390
|
+
# ── Memory stats ──────────────────────────────────────────
|
|
1391
|
+
memory: dict = {}
|
|
1392
|
+
try:
|
|
1393
|
+
from .memory_engine import get_stats as _mem_stats
|
|
1394
|
+
ms = _mem_stats(config.home)
|
|
1395
|
+
memory = {
|
|
1396
|
+
"total": ms.total_memories,
|
|
1397
|
+
"short_term": ms.short_term,
|
|
1398
|
+
"mid_term": ms.mid_term,
|
|
1399
|
+
"long_term": ms.long_term,
|
|
1400
|
+
"status": ms.status.value,
|
|
1401
|
+
}
|
|
1402
|
+
except Exception:
|
|
1403
|
+
pass
|
|
1404
|
+
|
|
1405
|
+
# ── Coordination board ────────────────────────────────────
|
|
1406
|
+
board: dict = {"summary": {}, "active": []}
|
|
1407
|
+
try:
|
|
1408
|
+
from .coordination import Board
|
|
1409
|
+
brd = Board(config.home)
|
|
1410
|
+
views = brd.get_task_views()
|
|
1411
|
+
total = len(views)
|
|
1412
|
+
done = sum(1 for v in views if v.status.value == "done")
|
|
1413
|
+
in_prog = sum(1 for v in views if v.status.value == "in_progress")
|
|
1414
|
+
claimed = sum(1 for v in views if v.status.value == "claimed")
|
|
1415
|
+
open_ = sum(1 for v in views if v.status.value == "open")
|
|
1416
|
+
active_tasks = [
|
|
1417
|
+
{
|
|
1418
|
+
"id": v.task.id,
|
|
1419
|
+
"title": v.task.title,
|
|
1420
|
+
"priority": v.task.priority.value,
|
|
1421
|
+
"status": v.status.value,
|
|
1422
|
+
"claimed_by": v.claimed_by,
|
|
1423
|
+
}
|
|
1424
|
+
for v in views
|
|
1425
|
+
if v.status.value in ("in_progress", "claimed")
|
|
1426
|
+
]
|
|
1427
|
+
board = {
|
|
1428
|
+
"summary": {
|
|
1429
|
+
"total": total,
|
|
1430
|
+
"done": done,
|
|
1431
|
+
"in_progress": in_prog,
|
|
1432
|
+
"claimed": claimed,
|
|
1433
|
+
"open": open_,
|
|
1434
|
+
},
|
|
1435
|
+
"active": active_tasks,
|
|
1436
|
+
}
|
|
1437
|
+
except Exception:
|
|
1438
|
+
pass
|
|
1439
|
+
|
|
1440
|
+
# ── Consciousness stats ───────────────────────────────────
|
|
1441
|
+
c_stats: dict = {}
|
|
1442
|
+
if consciousness:
|
|
1443
|
+
try:
|
|
1444
|
+
c_stats = dict(consciousness.stats)
|
|
1445
|
+
except Exception:
|
|
1446
|
+
pass
|
|
1447
|
+
|
|
1448
|
+
return {
|
|
1449
|
+
"agent": agent,
|
|
1450
|
+
"pillars": pillars,
|
|
1451
|
+
"memory": memory,
|
|
1452
|
+
"board": board,
|
|
1453
|
+
"consciousness": c_stats,
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
@staticmethod
|
|
1457
|
+
def _render_html(data: dict) -> str:
|
|
1458
|
+
"""Render dashboard data as a self-contained dark-theme HTML page."""
|
|
1459
|
+
agent = data.get("agent", {})
|
|
1460
|
+
d = data.get("daemon", {})
|
|
1461
|
+
cons = data.get("consciousness", {})
|
|
1462
|
+
backends = data.get("backends", {})
|
|
1463
|
+
conversations = data.get("conversations", [])
|
|
1464
|
+
system = data.get("system", {})
|
|
1465
|
+
errors = data.get("recent_errors", [])
|
|
1466
|
+
|
|
1467
|
+
# Uptime formatting
|
|
1468
|
+
secs = float(d.get("uptime_seconds", 0))
|
|
1469
|
+
if secs < 60:
|
|
1470
|
+
uptime_str = f"{int(secs)}s"
|
|
1471
|
+
elif secs < 3600:
|
|
1472
|
+
uptime_str = f"{int(secs // 60)}m {int(secs % 60)}s"
|
|
1473
|
+
else:
|
|
1474
|
+
uptime_str = f"{int(secs // 3600)}h {int((secs % 3600) // 60)}m"
|
|
1475
|
+
|
|
1476
|
+
# Fingerprint — shorten for display
|
|
1477
|
+
fp = agent.get("fingerprint", "")
|
|
1478
|
+
fp_short = f"{fp[:8]}\u2026{fp[-8:]}" if len(fp) > 20 else fp
|
|
1479
|
+
|
|
1480
|
+
# Consciousness card
|
|
1481
|
+
c_enabled = cons.get("enabled", False)
|
|
1482
|
+
c_dot = "dot-green" if c_enabled else "dot-red"
|
|
1483
|
+
c_inotify = cons.get("inotify_active", False)
|
|
1484
|
+
c_backends = cons.get("backends", [])
|
|
1485
|
+
c_backends_str = ", ".join(c_backends) if c_backends else "none"
|
|
1486
|
+
c_html = (
|
|
1487
|
+
f'<div class="stat-row"><span class="stat-label">'
|
|
1488
|
+
f'<span class="dot {c_dot}"></span>Status</span>'
|
|
1489
|
+
f'<span class="stat-value">{"active" if c_enabled else "disabled"}</span></div>'
|
|
1490
|
+
f'<div class="stat-row"><span class="stat-label">Processed</span>'
|
|
1491
|
+
f'<span class="stat-value">{cons.get("messages_processed", 0)}</span></div>'
|
|
1492
|
+
f'<div class="stat-row"><span class="stat-label">Responses sent</span>'
|
|
1493
|
+
f'<span class="stat-value">{cons.get("responses_sent", 0)}</span></div>'
|
|
1494
|
+
f'<div class="stat-row"><span class="stat-label">Errors</span>'
|
|
1495
|
+
f'<span class="stat-value">{cons.get("errors", 0)}</span></div>'
|
|
1496
|
+
f'<div class="stat-row"><span class="stat-label">iNotify</span>'
|
|
1497
|
+
f'<span class="stat-value">{"yes" if c_inotify else "no"}</span></div>'
|
|
1498
|
+
f'<div class="stat-row"><span class="stat-label">LLM backends</span>'
|
|
1499
|
+
f'<span class="stat-value" style="font-size:12px">{c_backends_str}</span></div>'
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
# Backend health card
|
|
1503
|
+
if backends:
|
|
1504
|
+
b_rows = []
|
|
1505
|
+
for bname, binfo in backends.items():
|
|
1506
|
+
avail = binfo.get("available", False) if isinstance(binfo, dict) else False
|
|
1507
|
+
dot = "dot-green" if avail else "dot-red"
|
|
1508
|
+
b_rows.append(
|
|
1509
|
+
f'<div class="stat-row"><span class="stat-label">'
|
|
1510
|
+
f'<span class="dot {dot}"></span>{bname}</span>'
|
|
1511
|
+
f'<span class="stat-value">{"ok" if avail else "down"}</span></div>'
|
|
1512
|
+
)
|
|
1513
|
+
b_html = "\n".join(b_rows)
|
|
1514
|
+
else:
|
|
1515
|
+
b_html = '<div style="color:#484f58;padding:4px 0;font-size:13px">No transports configured</div>'
|
|
1516
|
+
|
|
1517
|
+
# Conversations card
|
|
1518
|
+
if conversations:
|
|
1519
|
+
c_rows = []
|
|
1520
|
+
for conv in conversations:
|
|
1521
|
+
peer = conv.get("peer", "?")
|
|
1522
|
+
count = conv.get("message_count", 0)
|
|
1523
|
+
last = (conv.get("last_message") or "")[:10]
|
|
1524
|
+
c_rows.append(
|
|
1525
|
+
f'<div class="peer-row">'
|
|
1526
|
+
f'<span class="peer-name">{peer}</span>'
|
|
1527
|
+
f'<div><span class="peer-count">{count}</span>'
|
|
1528
|
+
f'<span style="color:#484f58;font-size:11px;margin-left:6px">{last}</span>'
|
|
1529
|
+
f'</div></div>'
|
|
1530
|
+
)
|
|
1531
|
+
conv_html = "\n".join(c_rows)
|
|
1532
|
+
else:
|
|
1533
|
+
conv_html = '<div style="color:#484f58;padding:4px 0">No conversations yet</div>'
|
|
1534
|
+
|
|
1535
|
+
# System stats card
|
|
1536
|
+
mem_used = system.get("memory_used_mb", 0)
|
|
1537
|
+
mem_total = system.get("memory_total_mb", 0)
|
|
1538
|
+
disk_free = system.get("disk_free_gb", 0)
|
|
1539
|
+
disk_total = system.get("disk_total_gb", 0)
|
|
1540
|
+
mem_pct = int(mem_used / mem_total * 100) if mem_total else 0
|
|
1541
|
+
disk_used_pct = int((disk_total - disk_free) / disk_total * 100) if disk_total else 0
|
|
1542
|
+
sys_html = (
|
|
1543
|
+
f'<div class="stat-row"><span class="stat-label">RAM used</span>'
|
|
1544
|
+
f'<span class="stat-value">{int(mem_used):,} / {int(mem_total):,} MB ({mem_pct}%)</span></div>'
|
|
1545
|
+
f'<div class="stat-row"><span class="stat-label">Disk used</span>'
|
|
1546
|
+
f'<span class="stat-value">{disk_total - disk_free:.1f} / {disk_total:.1f} GB</span></div>'
|
|
1547
|
+
f'<div class="stat-row"><span class="stat-label">Disk free</span>'
|
|
1548
|
+
f'<span class="stat-value">{disk_free:.1f} GB ({100 - disk_used_pct}%)</span></div>'
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
# Errors card
|
|
1552
|
+
if errors:
|
|
1553
|
+
err_lines = "\n".join(
|
|
1554
|
+
f'<div class="error-line">{str(e)[-100:]}</div>'
|
|
1555
|
+
for e in errors[-5:]
|
|
1556
|
+
)
|
|
1557
|
+
err_html = f'<div class="error-list">{err_lines}</div>'
|
|
1558
|
+
else:
|
|
1559
|
+
err_html = '<div style="color:#3fb950;font-size:13px">No recent errors</div>'
|
|
1560
|
+
|
|
1561
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
1562
|
+
agent_name = agent.get("name", "SKCapstone")
|
|
1563
|
+
pid = d.get("pid", "?")
|
|
1564
|
+
msg_count = d.get("messages_received", 0)
|
|
1565
|
+
syncs = d.get("syncs_completed", 0)
|
|
1566
|
+
|
|
1567
|
+
# CSS stored as plain string to avoid f-string brace escaping
|
|
1568
|
+
css = (
|
|
1569
|
+
"*{box-sizing:border-box;margin:0;padding:0}"
|
|
1570
|
+
"body{background:#0d1117;color:#c9d1d9;font-family:'Segoe UI',system-ui,sans-serif;font-size:14px}"
|
|
1571
|
+
"h1{font-size:20px;font-weight:600;color:#58a6ff}"
|
|
1572
|
+
"h2{font-size:11px;font-weight:600;color:#8b949e;text-transform:uppercase;"
|
|
1573
|
+
"letter-spacing:.08em;margin-bottom:10px}"
|
|
1574
|
+
"header{padding:14px 24px;border-bottom:1px solid #21262d;"
|
|
1575
|
+
"display:flex;align-items:center;gap:12px;flex-wrap:wrap}"
|
|
1576
|
+
".badge{font-size:11px;background:#161b22;border:1px solid #30363d;"
|
|
1577
|
+
"border-radius:4px;padding:2px 8px;color:#8b949e}"
|
|
1578
|
+
".badge.ok{border-color:#238636;color:#3fb950}"
|
|
1579
|
+
"main{padding:20px 24px;display:grid;"
|
|
1580
|
+
"grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}"
|
|
1581
|
+
".card{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:16px}"
|
|
1582
|
+
".stat-row{display:flex;justify-content:space-between;align-items:center;"
|
|
1583
|
+
"padding:5px 0;border-bottom:1px solid #21262d}"
|
|
1584
|
+
".stat-row:last-child{border-bottom:none}"
|
|
1585
|
+
".stat-label{color:#8b949e;font-size:13px}"
|
|
1586
|
+
".stat-value{color:#e6edf3;font-family:monospace;font-size:13px;"
|
|
1587
|
+
"text-align:right;max-width:55%}"
|
|
1588
|
+
".dot{display:inline-block;width:7px;height:7px;border-radius:50%;"
|
|
1589
|
+
"margin-right:5px;vertical-align:middle}"
|
|
1590
|
+
".dot-green{background:#3fb950;box-shadow:0 0 4px #3fb95077}"
|
|
1591
|
+
".dot-red{background:#f85149;box-shadow:0 0 4px #f8514977}"
|
|
1592
|
+
".peer-row{display:flex;justify-content:space-between;align-items:center;"
|
|
1593
|
+
"padding:6px 0;border-bottom:1px solid #21262d}"
|
|
1594
|
+
".peer-row:last-child{border-bottom:none}"
|
|
1595
|
+
".peer-name{color:#58a6ff;font-family:monospace;font-size:13px}"
|
|
1596
|
+
".peer-count{background:#1f6feb22;color:#79c0ff;border-radius:10px;"
|
|
1597
|
+
"padding:1px 7px;font-size:12px}"
|
|
1598
|
+
".error-list{font-family:monospace;font-size:11px;color:#f85149}"
|
|
1599
|
+
".error-line{padding:2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}"
|
|
1600
|
+
"footer{padding:10px 24px;border-top:1px solid #21262d;"
|
|
1601
|
+
"color:#484f58;font-size:11px;text-align:center}"
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1604
|
+
fp_badge = (
|
|
1605
|
+
f'<span class="badge" style="font-size:10px;font-family:monospace">{fp_short}</span>'
|
|
1606
|
+
if fp_short else ""
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
return (
|
|
1610
|
+
f'<!DOCTYPE html><html lang="en"><head>'
|
|
1611
|
+
f'<meta charset="UTF-8">'
|
|
1612
|
+
f'<meta name="viewport" content="width=device-width,initial-scale=1.0">'
|
|
1613
|
+
f'<title>SKCapstone \u2014 {agent_name}</title>'
|
|
1614
|
+
f'<meta http-equiv="refresh" content="30">'
|
|
1615
|
+
f'<style>{css}</style>'
|
|
1616
|
+
f'</head><body>'
|
|
1617
|
+
f'<header>'
|
|
1618
|
+
f'<h1>◆ {agent_name}</h1>'
|
|
1619
|
+
f'<span class="badge ok">DAEMON RUNNING</span>'
|
|
1620
|
+
f'<span class="badge">PID {pid}</span>'
|
|
1621
|
+
f'{fp_badge}'
|
|
1622
|
+
f'<span style="margin-left:auto;color:#484f58;font-size:11px">auto-refresh 30s</span>'
|
|
1623
|
+
f'</header>'
|
|
1624
|
+
f'<main>'
|
|
1625
|
+
f'<div class="card"><h2>Daemon</h2>'
|
|
1626
|
+
f'<div class="stat-row"><span class="stat-label">Uptime</span>'
|
|
1627
|
+
f'<span class="stat-value">{uptime_str}</span></div>'
|
|
1628
|
+
f'<div class="stat-row"><span class="stat-label">Messages received</span>'
|
|
1629
|
+
f'<span class="stat-value">{msg_count}</span></div>'
|
|
1630
|
+
f'<div class="stat-row"><span class="stat-label">Syncs completed</span>'
|
|
1631
|
+
f'<span class="stat-value">{syncs}</span></div>'
|
|
1632
|
+
f'</div>'
|
|
1633
|
+
f'<div class="card"><h2>Consciousness</h2>{c_html}</div>'
|
|
1634
|
+
f'<div class="card"><h2>Backends</h2>{b_html}</div>'
|
|
1635
|
+
f'<div class="card"><h2>Recent Conversations</h2>{conv_html}</div>'
|
|
1636
|
+
f'<div class="card"><h2>System</h2>{sys_html}</div>'
|
|
1637
|
+
f'<div class="card"><h2>Recent Errors</h2>{err_html}</div>'
|
|
1638
|
+
f'</main>'
|
|
1639
|
+
f'<footer>SKCapstone Daemon \u00b7 {ts}</footer>'
|
|
1640
|
+
f'</body></html>'
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
def _check_rate_limit(self) -> bool:
|
|
1644
|
+
"""Return True if the request is allowed; send 429 and return False otherwise."""
|
|
1645
|
+
ip = self.client_address[0]
|
|
1646
|
+
if not rate_limiter.is_allowed(ip):
|
|
1647
|
+
self._json_response(
|
|
1648
|
+
{"error": "rate limit exceeded", "retry_after_seconds": 60},
|
|
1649
|
+
status=429,
|
|
1650
|
+
)
|
|
1651
|
+
return False
|
|
1652
|
+
return True
|
|
1653
|
+
|
|
1654
|
+
def do_GET(self):
|
|
1655
|
+
"""Handle GET requests to the daemon API."""
|
|
1656
|
+
if not self._check_rate_limit():
|
|
1657
|
+
return
|
|
1658
|
+
if self.path == "/":
|
|
1659
|
+
self._html_response(self._render_html(self._build_dashboard_data()))
|
|
1660
|
+
elif self.path == "/api/v1/dashboard":
|
|
1661
|
+
self._json_response(self._build_dashboard_data())
|
|
1662
|
+
elif self.path == "/api/v1/health":
|
|
1663
|
+
snap = state.snapshot()
|
|
1664
|
+
healing = snap.get("self_healing", {})
|
|
1665
|
+
sys_stats = self._get_system_stats()
|
|
1666
|
+
c_enabled = False
|
|
1667
|
+
if consciousness:
|
|
1668
|
+
c_enabled = bool(consciousness.stats.get("enabled", False))
|
|
1669
|
+
self._json_response({
|
|
1670
|
+
"status": "ok" if snap["running"] else "stopped",
|
|
1671
|
+
"uptime_seconds": snap["uptime_seconds"],
|
|
1672
|
+
"daemon_pid": snap["pid"],
|
|
1673
|
+
"consciousness_enabled": c_enabled,
|
|
1674
|
+
"self_healing_last_run": healing.get("timestamp"),
|
|
1675
|
+
"self_healing_issues_found": healing.get("still_broken", 0),
|
|
1676
|
+
"self_healing_auto_fixed": healing.get("auto_fixed", 0),
|
|
1677
|
+
"backend_health": snap.get("transport_health", {}),
|
|
1678
|
+
"disk_free_gb": sys_stats.get("disk_free_gb", 0),
|
|
1679
|
+
"memory_usage_mb": sys_stats.get("memory_used_mb", 0),
|
|
1680
|
+
})
|
|
1681
|
+
elif self.path == "/status":
|
|
1682
|
+
snap = state.snapshot()
|
|
1683
|
+
snap["components"] = service._component_mgr.snapshot()
|
|
1684
|
+
self._json_response(snap)
|
|
1685
|
+
elif self.path == "/api/v1/components":
|
|
1686
|
+
self._json_response({"components": service._component_mgr.snapshot()})
|
|
1687
|
+
elif self.path == "/health":
|
|
1688
|
+
self._json_response(state.health_reports)
|
|
1689
|
+
elif self.path == "/consciousness":
|
|
1690
|
+
if consciousness:
|
|
1691
|
+
self._json_response(consciousness.stats)
|
|
1692
|
+
else:
|
|
1693
|
+
self._json_response({"enabled": False, "reason": "not loaded"})
|
|
1694
|
+
elif self.path == "/ping":
|
|
1695
|
+
self._json_response({"pong": True, "pid": os.getpid()})
|
|
1696
|
+
|
|
1697
|
+
# ── Activity SSE stream ───────────────────────────────────
|
|
1698
|
+
elif self.path == "/api/v1/activity":
|
|
1699
|
+
q: queue.Queue = queue.Queue(maxsize=200)
|
|
1700
|
+
_activity.register_client(q)
|
|
1701
|
+
self.send_response(200)
|
|
1702
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
1703
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1704
|
+
self.send_header("Connection", "keep-alive")
|
|
1705
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
1706
|
+
self._add_cors_headers()
|
|
1707
|
+
self.end_headers()
|
|
1708
|
+
try:
|
|
1709
|
+
# Replay history so late-joining clients see context
|
|
1710
|
+
for chunk in _activity.get_history_encoded():
|
|
1711
|
+
self.wfile.write(chunk)
|
|
1712
|
+
self.wfile.flush()
|
|
1713
|
+
# Stream live events; send keep-alive comments on timeout
|
|
1714
|
+
while not service._stop_event.is_set():
|
|
1715
|
+
try:
|
|
1716
|
+
chunk = q.get(timeout=15)
|
|
1717
|
+
self.wfile.write(chunk)
|
|
1718
|
+
self.wfile.flush()
|
|
1719
|
+
except queue.Empty:
|
|
1720
|
+
self.wfile.write(b": heartbeat\n\n")
|
|
1721
|
+
self.wfile.flush()
|
|
1722
|
+
except OSError:
|
|
1723
|
+
pass
|
|
1724
|
+
finally:
|
|
1725
|
+
_activity.unregister_client(q)
|
|
1726
|
+
return
|
|
1727
|
+
|
|
1728
|
+
# ── Vanilla-JS dashboard (single-file HTML) ───────────────
|
|
1729
|
+
elif self.path == "/dashboard":
|
|
1730
|
+
html_file = Path(__file__).parent / "dashboard.html"
|
|
1731
|
+
if html_file.exists():
|
|
1732
|
+
self._html_response(html_file.read_text(encoding="utf-8"))
|
|
1733
|
+
else:
|
|
1734
|
+
self._html_response(
|
|
1735
|
+
"<h1>dashboard.html not found</h1>", status=404
|
|
1736
|
+
)
|
|
1737
|
+
|
|
1738
|
+
# ── Capstone API (pillars + memory + board + consciousness) ─
|
|
1739
|
+
elif self.path == "/api/v1/capstone":
|
|
1740
|
+
self._json_response(self._build_capstone_data())
|
|
1741
|
+
|
|
1742
|
+
# ── WebSocket streaming endpoint ─────────────────────────
|
|
1743
|
+
elif self.path == "/ws":
|
|
1744
|
+
key = self.headers.get("Sec-WebSocket-Key", "")
|
|
1745
|
+
if self.headers.get("Upgrade", "").lower() != "websocket" or not key:
|
|
1746
|
+
self._json_response(
|
|
1747
|
+
{"error": "WebSocket upgrade required", "hint": "use ws://"},
|
|
1748
|
+
status=400,
|
|
1749
|
+
)
|
|
1750
|
+
return
|
|
1751
|
+
accept = _ws_accept_key(key)
|
|
1752
|
+
# Flush any pending write-buffer data before raw-socket takeover
|
|
1753
|
+
try:
|
|
1754
|
+
self.wfile.flush()
|
|
1755
|
+
except OSError:
|
|
1756
|
+
return
|
|
1757
|
+
# Send the 101 Switching Protocols response directly
|
|
1758
|
+
try:
|
|
1759
|
+
self.request.sendall((
|
|
1760
|
+
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
1761
|
+
"Upgrade: websocket\r\n"
|
|
1762
|
+
"Connection: Upgrade\r\n"
|
|
1763
|
+
f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
|
|
1764
|
+
).encode("ascii"))
|
|
1765
|
+
except OSError:
|
|
1766
|
+
return
|
|
1767
|
+
sock = self.request
|
|
1768
|
+
with service._ws_lock:
|
|
1769
|
+
service._ws_clients.add(sock)
|
|
1770
|
+
# Send initial state snapshot
|
|
1771
|
+
try:
|
|
1772
|
+
init_payload = json.dumps(
|
|
1773
|
+
{"type": "connected", "state": state.snapshot()},
|
|
1774
|
+
default=str,
|
|
1775
|
+
).encode("utf-8")
|
|
1776
|
+
sock.sendall(_ws_encode_frame(init_payload))
|
|
1777
|
+
except OSError:
|
|
1778
|
+
with service._ws_lock:
|
|
1779
|
+
service._ws_clients.discard(sock)
|
|
1780
|
+
return
|
|
1781
|
+
# Read loop: handle close frames and detect disconnects
|
|
1782
|
+
sock.settimeout(30)
|
|
1783
|
+
try:
|
|
1784
|
+
while not service._stop_event.is_set():
|
|
1785
|
+
try:
|
|
1786
|
+
result = _ws_read_frame(sock)
|
|
1787
|
+
except TimeoutError:
|
|
1788
|
+
continue # check stop_event, then resume
|
|
1789
|
+
except OSError:
|
|
1790
|
+
break
|
|
1791
|
+
if result is None:
|
|
1792
|
+
break
|
|
1793
|
+
opcode, _ = result
|
|
1794
|
+
if opcode == 0x8: # close frame
|
|
1795
|
+
try:
|
|
1796
|
+
sock.sendall(_ws_encode_close())
|
|
1797
|
+
except OSError:
|
|
1798
|
+
pass
|
|
1799
|
+
break
|
|
1800
|
+
finally:
|
|
1801
|
+
with service._ws_lock:
|
|
1802
|
+
service._ws_clients.discard(sock)
|
|
1803
|
+
|
|
1804
|
+
# ── Log stream WebSocket endpoint (CapAuth required) ─────
|
|
1805
|
+
elif self.path == "/api/v1/logs":
|
|
1806
|
+
key = self.headers.get("Sec-WebSocket-Key", "")
|
|
1807
|
+
if self.headers.get("Upgrade", "").lower() != "websocket" or not key:
|
|
1808
|
+
self._json_response(
|
|
1809
|
+
{"error": "WebSocket upgrade required", "hint": "use ws://"},
|
|
1810
|
+
status=400,
|
|
1811
|
+
)
|
|
1812
|
+
return
|
|
1813
|
+
|
|
1814
|
+
# Validate CapAuth bearer token before upgrading
|
|
1815
|
+
auth_header = self.headers.get("Authorization", "")
|
|
1816
|
+
token_str = auth_header[7:].strip() if auth_header.startswith("Bearer ") else None
|
|
1817
|
+
|
|
1818
|
+
fingerprint: Optional[str] = None
|
|
1819
|
+
try:
|
|
1820
|
+
from skcomm.capauth_validator import CapAuthValidator
|
|
1821
|
+
fingerprint = CapAuthValidator(require_auth=True).validate(token_str)
|
|
1822
|
+
except ImportError:
|
|
1823
|
+
# skcomm not installed — fall back to skcapstone signed tokens
|
|
1824
|
+
if token_str:
|
|
1825
|
+
try:
|
|
1826
|
+
from .tokens import import_token, verify_token
|
|
1827
|
+
tok = import_token(token_str)
|
|
1828
|
+
if verify_token(tok, home=config.home):
|
|
1829
|
+
fingerprint = tok.payload.issuer
|
|
1830
|
+
except Exception:
|
|
1831
|
+
fingerprint = None
|
|
1832
|
+
|
|
1833
|
+
if fingerprint is None:
|
|
1834
|
+
self.send_response(401)
|
|
1835
|
+
self.send_header("Content-Type", "application/json")
|
|
1836
|
+
self._add_cors_headers()
|
|
1837
|
+
self.end_headers()
|
|
1838
|
+
self.wfile.write(b'{"error": "unauthorized"}')
|
|
1839
|
+
return
|
|
1840
|
+
|
|
1841
|
+
# Perform WebSocket upgrade
|
|
1842
|
+
accept = _ws_accept_key(key)
|
|
1843
|
+
try:
|
|
1844
|
+
self.wfile.flush()
|
|
1845
|
+
except OSError:
|
|
1846
|
+
return
|
|
1847
|
+
try:
|
|
1848
|
+
self.request.sendall((
|
|
1849
|
+
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
1850
|
+
"Upgrade: websocket\r\n"
|
|
1851
|
+
"Connection: Upgrade\r\n"
|
|
1852
|
+
f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
|
|
1853
|
+
).encode("ascii"))
|
|
1854
|
+
except OSError:
|
|
1855
|
+
return
|
|
1856
|
+
|
|
1857
|
+
sock = self.request
|
|
1858
|
+
log_file = config.log_file
|
|
1859
|
+
stop = service._stop_event
|
|
1860
|
+
|
|
1861
|
+
# Send the last 50 lines from daemon.log, record EOF offset
|
|
1862
|
+
tail_offset: int = 0
|
|
1863
|
+
try:
|
|
1864
|
+
if log_file.exists():
|
|
1865
|
+
from collections import deque as _deque
|
|
1866
|
+
with open(log_file, encoding="utf-8", errors="replace") as _fh:
|
|
1867
|
+
tail_lines = list(_deque(_fh, maxlen=50))
|
|
1868
|
+
tail_offset = _fh.tell()
|
|
1869
|
+
for _line in tail_lines:
|
|
1870
|
+
_line = _line.rstrip("\n")
|
|
1871
|
+
_frame = _ws_encode_frame(
|
|
1872
|
+
json.dumps(
|
|
1873
|
+
{"type": "line", "line": _line}, default=str
|
|
1874
|
+
).encode("utf-8")
|
|
1875
|
+
)
|
|
1876
|
+
sock.sendall(_frame)
|
|
1877
|
+
except OSError:
|
|
1878
|
+
return
|
|
1879
|
+
|
|
1880
|
+
# Per-client tail thread: stream new log lines as they arrive
|
|
1881
|
+
def _tail_and_send(
|
|
1882
|
+
_sock=sock,
|
|
1883
|
+
_log=log_file,
|
|
1884
|
+
_stop=stop,
|
|
1885
|
+
_offset=tail_offset,
|
|
1886
|
+
):
|
|
1887
|
+
try:
|
|
1888
|
+
# Wait for the log file if it doesn't exist yet
|
|
1889
|
+
while not _stop.is_set() and not _log.exists():
|
|
1890
|
+
_stop.wait(timeout=1.0)
|
|
1891
|
+
if _stop.is_set():
|
|
1892
|
+
return
|
|
1893
|
+
with open(_log, encoding="utf-8", errors="replace") as _fh:
|
|
1894
|
+
_fh.seek(_offset)
|
|
1895
|
+
while not _stop.is_set():
|
|
1896
|
+
chunk = _fh.read()
|
|
1897
|
+
if chunk:
|
|
1898
|
+
for _ln in chunk.splitlines():
|
|
1899
|
+
_f = _ws_encode_frame(
|
|
1900
|
+
json.dumps(
|
|
1901
|
+
{"type": "line", "line": _ln},
|
|
1902
|
+
default=str,
|
|
1903
|
+
).encode("utf-8")
|
|
1904
|
+
)
|
|
1905
|
+
try:
|
|
1906
|
+
_sock.sendall(_f)
|
|
1907
|
+
except OSError:
|
|
1908
|
+
return
|
|
1909
|
+
_stop.wait(timeout=0.5)
|
|
1910
|
+
except OSError:
|
|
1911
|
+
pass
|
|
1912
|
+
|
|
1913
|
+
threading.Thread(
|
|
1914
|
+
target=_tail_and_send,
|
|
1915
|
+
name="ws-logs-tail",
|
|
1916
|
+
daemon=True,
|
|
1917
|
+
).start()
|
|
1918
|
+
|
|
1919
|
+
# Read loop: keep alive and detect client disconnect / close frame
|
|
1920
|
+
sock.settimeout(30)
|
|
1921
|
+
try:
|
|
1922
|
+
while not service._stop_event.is_set():
|
|
1923
|
+
try:
|
|
1924
|
+
result = _ws_read_frame(sock)
|
|
1925
|
+
except TimeoutError:
|
|
1926
|
+
continue
|
|
1927
|
+
except OSError:
|
|
1928
|
+
break
|
|
1929
|
+
if result is None:
|
|
1930
|
+
break
|
|
1931
|
+
opcode, _ = result
|
|
1932
|
+
if opcode == 0x8: # close frame
|
|
1933
|
+
try:
|
|
1934
|
+
sock.sendall(_ws_encode_close())
|
|
1935
|
+
except OSError:
|
|
1936
|
+
pass
|
|
1937
|
+
break
|
|
1938
|
+
finally:
|
|
1939
|
+
pass # tail thread is daemon — exits when socket closes
|
|
1940
|
+
|
|
1941
|
+
# ── Household: list all agents ───────────────────────────
|
|
1942
|
+
elif self.path == "/api/v1/household/agents":
|
|
1943
|
+
agents = []
|
|
1944
|
+
agents_dir = config.shared_root / "agents"
|
|
1945
|
+
heartbeats_dir = config.shared_root / "heartbeats"
|
|
1946
|
+
|
|
1947
|
+
if agents_dir.exists():
|
|
1948
|
+
for agent_dir in sorted(agents_dir.iterdir()):
|
|
1949
|
+
if not agent_dir.is_dir():
|
|
1950
|
+
continue
|
|
1951
|
+
agent_name = agent_dir.name
|
|
1952
|
+
entry: dict = {"name": agent_name}
|
|
1953
|
+
|
|
1954
|
+
identity_path = agent_dir / "identity" / "identity.json"
|
|
1955
|
+
if identity_path.exists():
|
|
1956
|
+
try:
|
|
1957
|
+
entry["identity"] = json.loads(
|
|
1958
|
+
identity_path.read_text(encoding="utf-8")
|
|
1959
|
+
)
|
|
1960
|
+
except Exception:
|
|
1961
|
+
pass
|
|
1962
|
+
|
|
1963
|
+
hb_path = heartbeats_dir / f"{agent_name.lower()}.json"
|
|
1964
|
+
if hb_path.exists():
|
|
1965
|
+
try:
|
|
1966
|
+
hb = json.loads(hb_path.read_text(encoding="utf-8"))
|
|
1967
|
+
alive = self._hb_alive(hb)
|
|
1968
|
+
hb["alive"] = alive
|
|
1969
|
+
entry["heartbeat"] = hb
|
|
1970
|
+
entry["status"] = hb.get("status", "unknown") if alive else "stale"
|
|
1971
|
+
except Exception:
|
|
1972
|
+
entry["status"] = "unknown"
|
|
1973
|
+
else:
|
|
1974
|
+
entry["status"] = "no_heartbeat"
|
|
1975
|
+
|
|
1976
|
+
if consciousness:
|
|
1977
|
+
entry["consciousness"] = consciousness.stats
|
|
1978
|
+
|
|
1979
|
+
agents.append(entry)
|
|
1980
|
+
|
|
1981
|
+
self._json_response({"agents": agents})
|
|
1982
|
+
|
|
1983
|
+
# ── Household: single agent detail ───────────────────────
|
|
1984
|
+
elif self.path.startswith("/api/v1/household/agent/"):
|
|
1985
|
+
name = self.path[len("/api/v1/household/agent/"):].split("?")[0].rstrip("/")
|
|
1986
|
+
if not name:
|
|
1987
|
+
self._json_response({"error": "agent name required"}, status=400)
|
|
1988
|
+
return
|
|
1989
|
+
|
|
1990
|
+
agent_dir = config.shared_root / "agents" / name
|
|
1991
|
+
if not agent_dir.exists():
|
|
1992
|
+
self._json_response({"error": f"agent '{name}' not found"}, status=404)
|
|
1993
|
+
return
|
|
1994
|
+
|
|
1995
|
+
entry = {"name": name}
|
|
1996
|
+
|
|
1997
|
+
identity_path = agent_dir / "identity" / "identity.json"
|
|
1998
|
+
if identity_path.exists():
|
|
1999
|
+
try:
|
|
2000
|
+
entry["identity"] = json.loads(
|
|
2001
|
+
identity_path.read_text(encoding="utf-8")
|
|
2002
|
+
)
|
|
2003
|
+
except Exception:
|
|
2004
|
+
pass
|
|
2005
|
+
|
|
2006
|
+
hb_path = config.shared_root / "heartbeats" / f"{name.lower()}.json"
|
|
2007
|
+
if hb_path.exists():
|
|
2008
|
+
try:
|
|
2009
|
+
hb = json.loads(hb_path.read_text(encoding="utf-8"))
|
|
2010
|
+
alive = self._hb_alive(hb)
|
|
2011
|
+
hb["alive"] = alive
|
|
2012
|
+
entry["heartbeat"] = hb
|
|
2013
|
+
entry["status"] = hb.get("status", "unknown") if alive else "stale"
|
|
2014
|
+
except Exception:
|
|
2015
|
+
pass
|
|
2016
|
+
|
|
2017
|
+
memory_dir = agent_dir / "memory"
|
|
2018
|
+
if memory_dir.exists():
|
|
2019
|
+
count = 0
|
|
2020
|
+
for layer in ("short-term", "mid-term", "long-term"):
|
|
2021
|
+
layer_dir = memory_dir / layer
|
|
2022
|
+
if layer_dir.exists():
|
|
2023
|
+
count += sum(1 for _ in layer_dir.glob("*.json"))
|
|
2024
|
+
entry["memory_count"] = count
|
|
2025
|
+
|
|
2026
|
+
conversations_dir = config.shared_root / "conversations"
|
|
2027
|
+
conv_list = []
|
|
2028
|
+
if conversations_dir.exists():
|
|
2029
|
+
for cf in sorted(conversations_dir.glob("*.json"))[:10]:
|
|
2030
|
+
try:
|
|
2031
|
+
msgs = json.loads(cf.read_text(encoding="utf-8"))
|
|
2032
|
+
if isinstance(msgs, list):
|
|
2033
|
+
conv_list.append({
|
|
2034
|
+
"peer": cf.stem,
|
|
2035
|
+
"message_count": len(msgs),
|
|
2036
|
+
"last_message": msgs[-1].get("timestamp") if msgs else None,
|
|
2037
|
+
})
|
|
2038
|
+
except Exception:
|
|
2039
|
+
pass
|
|
2040
|
+
entry["recent_conversations"] = conv_list
|
|
2041
|
+
|
|
2042
|
+
if consciousness:
|
|
2043
|
+
entry["consciousness"] = consciousness.stats
|
|
2044
|
+
|
|
2045
|
+
self._json_response(entry)
|
|
2046
|
+
|
|
2047
|
+
# ── Conversations: list all ───────────────────────────────
|
|
2048
|
+
elif self.path == "/api/v1/conversations":
|
|
2049
|
+
conversations = []
|
|
2050
|
+
conversations_dir = config.shared_root / "conversations"
|
|
2051
|
+
if conversations_dir.exists():
|
|
2052
|
+
for cf in sorted(
|
|
2053
|
+
conversations_dir.glob("*.json"),
|
|
2054
|
+
key=lambda p: p.stat().st_mtime,
|
|
2055
|
+
reverse=True,
|
|
2056
|
+
):
|
|
2057
|
+
try:
|
|
2058
|
+
msgs = json.loads(cf.read_text(encoding="utf-8"))
|
|
2059
|
+
if isinstance(msgs, list):
|
|
2060
|
+
last_msg = msgs[-1] if msgs else {}
|
|
2061
|
+
last_content = last_msg.get("content", last_msg.get("message", ""))
|
|
2062
|
+
conversations.append({
|
|
2063
|
+
"peer": cf.stem,
|
|
2064
|
+
"message_count": len(msgs),
|
|
2065
|
+
"last_message_time": last_msg.get("timestamp") if msgs else None,
|
|
2066
|
+
"last_message_preview": (last_content or "")[:120],
|
|
2067
|
+
})
|
|
2068
|
+
except Exception:
|
|
2069
|
+
pass
|
|
2070
|
+
self._json_response({"conversations": conversations})
|
|
2071
|
+
|
|
2072
|
+
# ── Conversations: single peer history ────────────────────
|
|
2073
|
+
elif self.path.startswith("/api/v1/conversations/"):
|
|
2074
|
+
raw_peer = self.path[len("/api/v1/conversations/"):].split("?")[0].rstrip("/")
|
|
2075
|
+
# Strip trailing /send so GET on .../peer (not /send) is unambiguous
|
|
2076
|
+
if raw_peer.endswith("/send"):
|
|
2077
|
+
self._json_response({"error": "use POST for /send"}, status=405)
|
|
2078
|
+
return
|
|
2079
|
+
peer = _sanitize_peer(raw_peer)
|
|
2080
|
+
if not peer:
|
|
2081
|
+
self._json_response({"error": "peer name required"}, status=400)
|
|
2082
|
+
return
|
|
2083
|
+
|
|
2084
|
+
conv_file = config.shared_root / "conversations" / f"{peer}.json"
|
|
2085
|
+
if not conv_file.exists():
|
|
2086
|
+
self._json_response({"error": f"no conversation with '{peer}'"}, status=404)
|
|
2087
|
+
return
|
|
2088
|
+
|
|
2089
|
+
try:
|
|
2090
|
+
msgs = json.loads(conv_file.read_text(encoding="utf-8"))
|
|
2091
|
+
self._json_response({"peer": peer, "messages": msgs})
|
|
2092
|
+
except Exception as exc:
|
|
2093
|
+
self._json_response({"error": str(exc)}, status=500)
|
|
2094
|
+
|
|
2095
|
+
# ── Metrics: consciousness loop runtime stats ─────────────
|
|
2096
|
+
elif self.path == "/api/v1/metrics":
|
|
2097
|
+
if consciousness:
|
|
2098
|
+
self._json_response(consciousness.metrics.to_dict())
|
|
2099
|
+
else:
|
|
2100
|
+
self._json_response({"error": "consciousness not loaded"}, status=503)
|
|
2101
|
+
|
|
2102
|
+
else:
|
|
2103
|
+
self._json_response(
|
|
2104
|
+
{
|
|
2105
|
+
"endpoints": [
|
|
2106
|
+
"/ (HTML dashboard)",
|
|
2107
|
+
"/dashboard (vanilla-JS polling dashboard)",
|
|
2108
|
+
"/api/v1/capstone (pillars + memory + board + consciousness)",
|
|
2109
|
+
"/api/v1/dashboard",
|
|
2110
|
+
"/api/v1/health",
|
|
2111
|
+
"/status",
|
|
2112
|
+
"/health",
|
|
2113
|
+
"/consciousness",
|
|
2114
|
+
"/ping",
|
|
2115
|
+
"/api/v1/household/agents",
|
|
2116
|
+
"/api/v1/household/agent/{name}",
|
|
2117
|
+
"/api/v1/conversations",
|
|
2118
|
+
"/api/v1/conversations/{peer}",
|
|
2119
|
+
"POST /api/v1/conversations/{peer}/send",
|
|
2120
|
+
"DELETE /api/v1/conversations/{peer}",
|
|
2121
|
+
"/api/v1/components",
|
|
2122
|
+
"/api/v1/activity (SSE activity stream)",
|
|
2123
|
+
"/api/v1/metrics",
|
|
2124
|
+
"/ws (WebSocket streaming)",
|
|
2125
|
+
"/api/v1/logs (WebSocket log stream, CapAuth required)",
|
|
2126
|
+
]
|
|
2127
|
+
},
|
|
2128
|
+
status=200,
|
|
2129
|
+
)
|
|
2130
|
+
|
|
2131
|
+
def do_POST(self):
|
|
2132
|
+
"""Handle POST requests — conversation send endpoint."""
|
|
2133
|
+
if not self._check_rate_limit():
|
|
2134
|
+
return
|
|
2135
|
+
# ── POST /api/v1/conversations/{peer}/send ────────────────
|
|
2136
|
+
if self.path.startswith("/api/v1/conversations/") and self.path.endswith("/send"):
|
|
2137
|
+
raw_peer = self.path[len("/api/v1/conversations/"):-len("/send")]
|
|
2138
|
+
peer = _sanitize_peer(raw_peer)
|
|
2139
|
+
if not peer:
|
|
2140
|
+
self._json_response({"error": "invalid peer name"}, status=400)
|
|
2141
|
+
return
|
|
2142
|
+
|
|
2143
|
+
# Read and parse JSON body
|
|
2144
|
+
try:
|
|
2145
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
2146
|
+
body = self.rfile.read(length) if length > 0 else b"{}"
|
|
2147
|
+
data = json.loads(body)
|
|
2148
|
+
except Exception:
|
|
2149
|
+
self._json_response({"error": "invalid JSON body"}, status=400)
|
|
2150
|
+
return
|
|
2151
|
+
|
|
2152
|
+
content = (data.get("content") or "").strip()
|
|
2153
|
+
if not content:
|
|
2154
|
+
self._json_response({"error": "content is required"}, status=400)
|
|
2155
|
+
return
|
|
2156
|
+
|
|
2157
|
+
message_id = str(uuid.uuid4())
|
|
2158
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
2159
|
+
|
|
2160
|
+
# Build SKComm envelope
|
|
2161
|
+
envelope = {
|
|
2162
|
+
"message_id": message_id,
|
|
2163
|
+
"sender": "api",
|
|
2164
|
+
"recipient": peer,
|
|
2165
|
+
"timestamp": ts,
|
|
2166
|
+
"payload": {
|
|
2167
|
+
"content": content,
|
|
2168
|
+
"content_type": "text",
|
|
2169
|
+
},
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
# Write to SKComm outbox
|
|
2173
|
+
try:
|
|
2174
|
+
outbox = config.shared_root / "sync" / "comms" / "outbox"
|
|
2175
|
+
outbox.mkdir(parents=True, exist_ok=True)
|
|
2176
|
+
(outbox / f"{message_id}.skc.json").write_text(
|
|
2177
|
+
json.dumps(envelope, indent=2), encoding="utf-8"
|
|
2178
|
+
)
|
|
2179
|
+
except Exception as exc:
|
|
2180
|
+
logger.warning("Outbox write failed for %s: %s", peer, exc)
|
|
2181
|
+
|
|
2182
|
+
# Process through consciousness loop if available (generates response)
|
|
2183
|
+
if consciousness and consciousness._config.enabled:
|
|
2184
|
+
try:
|
|
2185
|
+
from types import SimpleNamespace
|
|
2186
|
+
fake_payload = SimpleNamespace(
|
|
2187
|
+
content=content,
|
|
2188
|
+
content_type=SimpleNamespace(value="text"),
|
|
2189
|
+
)
|
|
2190
|
+
fake_env = SimpleNamespace(sender=peer, payload=fake_payload)
|
|
2191
|
+
threading.Thread(
|
|
2192
|
+
target=consciousness.process_envelope,
|
|
2193
|
+
args=(fake_env,),
|
|
2194
|
+
daemon=True,
|
|
2195
|
+
).start()
|
|
2196
|
+
except Exception as exc:
|
|
2197
|
+
logger.debug("Consciousness process skipped: %s", exc)
|
|
2198
|
+
|
|
2199
|
+
self._json_response({"status": "sent", "message_id": message_id})
|
|
2200
|
+
return
|
|
2201
|
+
|
|
2202
|
+
self._json_response({"error": "not found"}, status=404)
|
|
2203
|
+
|
|
2204
|
+
def do_DELETE(self):
|
|
2205
|
+
"""Handle DELETE requests — clear conversation history."""
|
|
2206
|
+
if not self._check_rate_limit():
|
|
2207
|
+
return
|
|
2208
|
+
# ── DELETE /api/v1/conversations/{peer} ──────────────────
|
|
2209
|
+
if self.path.startswith("/api/v1/conversations/"):
|
|
2210
|
+
raw_peer = self.path[len("/api/v1/conversations/"):].split("?")[0].rstrip("/")
|
|
2211
|
+
# Reject sub-paths like /send
|
|
2212
|
+
if "/" in raw_peer:
|
|
2213
|
+
self._json_response({"error": "invalid path"}, status=400)
|
|
2214
|
+
return
|
|
2215
|
+
peer = _sanitize_peer(raw_peer)
|
|
2216
|
+
if not peer:
|
|
2217
|
+
self._json_response({"error": "invalid peer name"}, status=400)
|
|
2218
|
+
return
|
|
2219
|
+
|
|
2220
|
+
conv_file = config.shared_root / "conversations" / f"{peer}.json"
|
|
2221
|
+
if not conv_file.exists():
|
|
2222
|
+
self._json_response({"error": f"no conversation with '{peer}'"}, status=404)
|
|
2223
|
+
return
|
|
2224
|
+
|
|
2225
|
+
try:
|
|
2226
|
+
conv_file.unlink()
|
|
2227
|
+
self._json_response({"status": "deleted", "peer": peer})
|
|
2228
|
+
except Exception as exc:
|
|
2229
|
+
self._json_response({"error": str(exc)}, status=500)
|
|
2230
|
+
return
|
|
2231
|
+
|
|
2232
|
+
self._json_response({"error": "not found"}, status=404)
|
|
2233
|
+
|
|
2234
|
+
def do_OPTIONS(self):
|
|
2235
|
+
"""Handle OPTIONS preflight requests for CORS."""
|
|
2236
|
+
self.send_response(204)
|
|
2237
|
+
self._add_cors_headers()
|
|
2238
|
+
self.end_headers()
|
|
2239
|
+
|
|
2240
|
+
def _add_cors_headers(self):
|
|
2241
|
+
"""Add CORS headers to allow Flutter web access."""
|
|
2242
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2243
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
2244
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
2245
|
+
|
|
2246
|
+
def _json_response(self, data: dict, status: int = 200):
|
|
2247
|
+
self.send_response(status)
|
|
2248
|
+
self.send_header("Content-Type", "application/json")
|
|
2249
|
+
self._add_cors_headers()
|
|
2250
|
+
self.end_headers()
|
|
2251
|
+
self.wfile.write(json.dumps(data, indent=2, default=str).encode())
|
|
2252
|
+
|
|
2253
|
+
def _html_response(self, html: str, status: int = 200):
|
|
2254
|
+
body = html.encode("utf-8")
|
|
2255
|
+
self.send_response(status)
|
|
2256
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
2257
|
+
self.send_header("Content-Length", str(len(body)))
|
|
2258
|
+
self._add_cors_headers()
|
|
2259
|
+
self.end_headers()
|
|
2260
|
+
self.wfile.write(body)
|
|
2261
|
+
|
|
2262
|
+
def log_message(self, format, *args):
|
|
2263
|
+
logger.debug("API: %s", format % args)
|
|
2264
|
+
|
|
2265
|
+
try:
|
|
2266
|
+
self._server = ThreadingHTTPServer(("127.0.0.1", config.port), DaemonHandler)
|
|
2267
|
+
|
|
2268
|
+
if config.tls_enabled:
|
|
2269
|
+
from .tls import build_ssl_context, cert_fingerprint_sha256, ensure_tls_cert
|
|
2270
|
+
|
|
2271
|
+
cert_path, key_path = ensure_tls_cert(config.tls_dir)
|
|
2272
|
+
ssl_ctx = build_ssl_context(cert_path, key_path)
|
|
2273
|
+
self._server.socket = ssl_ctx.wrap_socket(
|
|
2274
|
+
self._server.socket, server_side=True
|
|
2275
|
+
)
|
|
2276
|
+
fingerprint = cert_fingerprint_sha256(cert_path)
|
|
2277
|
+
logger.info(
|
|
2278
|
+
"TLS enabled — certificate: %s fingerprint(SHA-256): %s",
|
|
2279
|
+
cert_path,
|
|
2280
|
+
fingerprint,
|
|
2281
|
+
)
|
|
2282
|
+
scheme = "https"
|
|
2283
|
+
else:
|
|
2284
|
+
scheme = "http"
|
|
2285
|
+
|
|
2286
|
+
t = threading.Thread(
|
|
2287
|
+
target=self._server.serve_forever,
|
|
2288
|
+
name="daemon-api",
|
|
2289
|
+
daemon=True,
|
|
2290
|
+
)
|
|
2291
|
+
t.start()
|
|
2292
|
+
self._threads.append(t)
|
|
2293
|
+
logger.info("API server listening on %s://127.0.0.1:%d", scheme, config.port)
|
|
2294
|
+
except OSError as exc:
|
|
2295
|
+
logger.error("Failed to start API server: %s", exc)
|
|
2296
|
+
self.state.record_error(f"API server: {exc}")
|
|
2297
|
+
|
|
2298
|
+
def _setup_logging(self) -> None:
|
|
2299
|
+
"""Configure structured JSON file logging and console logging."""
|
|
2300
|
+
from .log_config import configure_logging
|
|
2301
|
+
|
|
2302
|
+
configure_logging(self.config.log_file)
|
|
2303
|
+
|
|
2304
|
+
def _setup_signals(self) -> None:
|
|
2305
|
+
"""Register signal handlers for graceful shutdown."""
|
|
2306
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
2307
|
+
signal.signal(sig, self._handle_signal)
|
|
2308
|
+
|
|
2309
|
+
def _handle_signal(self, signum, frame):
|
|
2310
|
+
"""Handle shutdown signals."""
|
|
2311
|
+
logger.info("Received signal %s — stopping", signal.Signals(signum).name)
|
|
2312
|
+
self._stop_event.set()
|
|
2313
|
+
|
|
2314
|
+
def _save_shutdown_state(self) -> None:
|
|
2315
|
+
"""Persist in-flight messages and metrics to disk on shutdown.
|
|
2316
|
+
|
|
2317
|
+
Writes ``shutdown_state.json`` to the agent home directory so the
|
|
2318
|
+
next startup can detect and resume any messages that were mid-flight
|
|
2319
|
+
when the daemon was stopped.
|
|
2320
|
+
"""
|
|
2321
|
+
state_path = self.config.home / SHUTDOWN_STATE_FILE
|
|
2322
|
+
inflight = self.state.get_inflight()
|
|
2323
|
+
data = {
|
|
2324
|
+
"shutdown_at": datetime.now(timezone.utc).isoformat(),
|
|
2325
|
+
"inflight_messages": inflight,
|
|
2326
|
+
"metrics": {
|
|
2327
|
+
"messages_received": self.state.messages_received,
|
|
2328
|
+
"syncs_completed": self.state.syncs_completed,
|
|
2329
|
+
},
|
|
2330
|
+
}
|
|
2331
|
+
try:
|
|
2332
|
+
self.config.home.mkdir(parents=True, exist_ok=True)
|
|
2333
|
+
state_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
2334
|
+
logger.info(
|
|
2335
|
+
"Shutdown state saved — %d in-flight message(s) persisted",
|
|
2336
|
+
len(inflight),
|
|
2337
|
+
)
|
|
2338
|
+
except Exception as exc:
|
|
2339
|
+
logger.error("Failed to save shutdown state: %s", exc)
|
|
2340
|
+
|
|
2341
|
+
def _load_startup_state(self) -> None:
|
|
2342
|
+
"""Load persisted shutdown state on startup.
|
|
2343
|
+
|
|
2344
|
+
If a ``shutdown_state.json`` file exists from a previous run, restores
|
|
2345
|
+
the cumulative metrics and re-queues any in-flight messages through the
|
|
2346
|
+
consciousness loop. The state file is removed after successful load.
|
|
2347
|
+
"""
|
|
2348
|
+
state_path = self.config.home / SHUTDOWN_STATE_FILE
|
|
2349
|
+
if not state_path.exists():
|
|
2350
|
+
return
|
|
2351
|
+
|
|
2352
|
+
try:
|
|
2353
|
+
data = json.loads(state_path.read_text(encoding="utf-8"))
|
|
2354
|
+
except Exception as exc:
|
|
2355
|
+
logger.warning("Could not read shutdown state: %s", exc)
|
|
2356
|
+
return
|
|
2357
|
+
|
|
2358
|
+
shutdown_at = data.get("shutdown_at", "unknown")
|
|
2359
|
+
metrics = data.get("metrics", {})
|
|
2360
|
+
with self.state._lock:
|
|
2361
|
+
self.state.messages_received += metrics.get("messages_received", 0)
|
|
2362
|
+
self.state.syncs_completed += metrics.get("syncs_completed", 0)
|
|
2363
|
+
|
|
2364
|
+
inflight = data.get("inflight_messages", [])
|
|
2365
|
+
if inflight:
|
|
2366
|
+
logger.warning(
|
|
2367
|
+
"Resuming %d in-flight message(s) from previous shutdown at %s",
|
|
2368
|
+
len(inflight),
|
|
2369
|
+
shutdown_at,
|
|
2370
|
+
)
|
|
2371
|
+
self._resume_inflight_messages(inflight)
|
|
2372
|
+
else:
|
|
2373
|
+
logger.info("Startup state loaded — no in-flight messages to resume")
|
|
2374
|
+
|
|
2375
|
+
try:
|
|
2376
|
+
state_path.unlink()
|
|
2377
|
+
except Exception as exc:
|
|
2378
|
+
logger.warning("Could not remove shutdown state file: %s", exc)
|
|
2379
|
+
|
|
2380
|
+
def _resume_inflight_messages(self, inflight: list) -> None:
|
|
2381
|
+
"""Re-queue in-flight messages from a previous run.
|
|
2382
|
+
|
|
2383
|
+
Each message is reconstructed as a lightweight namespace envelope and
|
|
2384
|
+
dispatched to the consciousness loop. If consciousness is not available
|
|
2385
|
+
the messages are logged as dropped so nothing is silently lost.
|
|
2386
|
+
|
|
2387
|
+
Args:
|
|
2388
|
+
inflight: List of serialized message dicts from ``shutdown_state.json``.
|
|
2389
|
+
"""
|
|
2390
|
+
if not (self._consciousness and self._consciousness._config.enabled):
|
|
2391
|
+
logger.warning(
|
|
2392
|
+
"Consciousness not available — dropping %d in-flight message(s)",
|
|
2393
|
+
len(inflight),
|
|
2394
|
+
)
|
|
2395
|
+
for msg in inflight:
|
|
2396
|
+
logger.warning(
|
|
2397
|
+
" dropped: %s from %s",
|
|
2398
|
+
msg.get("message_id"),
|
|
2399
|
+
msg.get("sender"),
|
|
2400
|
+
)
|
|
2401
|
+
return
|
|
2402
|
+
|
|
2403
|
+
from types import SimpleNamespace
|
|
2404
|
+
|
|
2405
|
+
for msg in inflight:
|
|
2406
|
+
try:
|
|
2407
|
+
fake_payload = SimpleNamespace(
|
|
2408
|
+
content=msg.get("content", ""),
|
|
2409
|
+
content_type=SimpleNamespace(value=msg.get("content_type", "text")),
|
|
2410
|
+
)
|
|
2411
|
+
fake_env = SimpleNamespace(
|
|
2412
|
+
message_id=msg.get("message_id", str(uuid.uuid4())),
|
|
2413
|
+
sender=msg.get("sender", "unknown"),
|
|
2414
|
+
payload=fake_payload,
|
|
2415
|
+
)
|
|
2416
|
+
self._consciousness.process_envelope(fake_env)
|
|
2417
|
+
logger.info(
|
|
2418
|
+
"Resumed in-flight message %s from %s",
|
|
2419
|
+
msg.get("message_id"),
|
|
2420
|
+
msg.get("sender"),
|
|
2421
|
+
)
|
|
2422
|
+
except Exception as exc:
|
|
2423
|
+
logger.error(
|
|
2424
|
+
"Failed to resume message %s: %s",
|
|
2425
|
+
msg.get("message_id"),
|
|
2426
|
+
exc,
|
|
2427
|
+
)
|
|
2428
|
+
|
|
2429
|
+
def _write_pid(self) -> None:
|
|
2430
|
+
"""Write the PID file."""
|
|
2431
|
+
pid_path = self.config.home / PID_FILE
|
|
2432
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2433
|
+
pid_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
2434
|
+
|
|
2435
|
+
def _remove_pid(self) -> None:
|
|
2436
|
+
"""Remove the PID file."""
|
|
2437
|
+
pid_path = self.config.home / PID_FILE
|
|
2438
|
+
if pid_path.exists():
|
|
2439
|
+
pid_path.unlink()
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
def read_pid(home: Optional[Path] = None) -> Optional[int]:
|
|
2443
|
+
"""Read the daemon PID from the PID file.
|
|
2444
|
+
|
|
2445
|
+
Args:
|
|
2446
|
+
home: Agent home directory.
|
|
2447
|
+
|
|
2448
|
+
Returns:
|
|
2449
|
+
PID as int, or None if not running.
|
|
2450
|
+
"""
|
|
2451
|
+
home = (home or Path(AGENT_HOME)).expanduser()
|
|
2452
|
+
pid_path = home / PID_FILE
|
|
2453
|
+
if not pid_path.exists():
|
|
2454
|
+
return None
|
|
2455
|
+
try:
|
|
2456
|
+
pid = int(pid_path.read_text(encoding="utf-8").strip())
|
|
2457
|
+
os.kill(pid, 0)
|
|
2458
|
+
return pid
|
|
2459
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
2460
|
+
pid_path.unlink(missing_ok=True)
|
|
2461
|
+
return None
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def is_running(home: Optional[Path] = None) -> bool:
|
|
2465
|
+
"""Check if the daemon is currently running.
|
|
2466
|
+
|
|
2467
|
+
Args:
|
|
2468
|
+
home: Agent home directory.
|
|
2469
|
+
|
|
2470
|
+
Returns:
|
|
2471
|
+
True if daemon process is alive.
|
|
2472
|
+
"""
|
|
2473
|
+
return read_pid(home) is not None
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
def get_daemon_status(home: Optional[Path] = None, port: int = DEFAULT_PORT) -> Optional[dict]:
|
|
2477
|
+
"""Query the running daemon's status via HTTP API.
|
|
2478
|
+
|
|
2479
|
+
Args:
|
|
2480
|
+
home: Agent home directory.
|
|
2481
|
+
port: API port to query.
|
|
2482
|
+
|
|
2483
|
+
Returns:
|
|
2484
|
+
Status dict from the daemon, or None if unreachable.
|
|
2485
|
+
"""
|
|
2486
|
+
import urllib.request
|
|
2487
|
+
import urllib.error
|
|
2488
|
+
|
|
2489
|
+
try:
|
|
2490
|
+
url = f"http://127.0.0.1:{port}/status"
|
|
2491
|
+
with urllib.request.urlopen(url, timeout=3) as resp:
|
|
2492
|
+
return json.loads(resp.read())
|
|
2493
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
|
2494
|
+
return None
|