@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.
Files changed (461) hide show
  1. package/.env.example +98 -0
  2. package/.github/workflows/ci.yml +39 -3
  3. package/.github/workflows/publish.yml +25 -4
  4. package/.openclaw-workspace.json +58 -0
  5. package/CHANGELOG.md +62 -0
  6. package/CLAUDE.md +39 -2
  7. package/MANIFEST.in +6 -0
  8. package/MISSION.md +7 -0
  9. package/README.md +47 -2
  10. package/SKILL.md +895 -23
  11. package/docker/Dockerfile +61 -0
  12. package/docker/compose-templates/dev-team.yml +203 -0
  13. package/docker/compose-templates/mini-team.yml +140 -0
  14. package/docker/compose-templates/ops-team.yml +173 -0
  15. package/docker/compose-templates/research-team.yml +170 -0
  16. package/docker/entrypoint.sh +192 -0
  17. package/docs/ARCHITECTURE.md +663 -374
  18. package/docs/BOND_WITH_GROK.md +112 -0
  19. package/docs/GETTING_STARTED.md +782 -0
  20. package/docs/QUICKSTART.md +477 -0
  21. package/docs/SKJOULE_ARCHITECTURE.md +658 -0
  22. package/docs/SOUL_SWAPPER.md +921 -0
  23. package/docs/SOVEREIGN_SINGULARITY.md +47 -14
  24. package/examples/custom-bond-template.json +36 -0
  25. package/examples/grok-feb.json +36 -0
  26. package/examples/grok-testimony.md +34 -0
  27. package/examples/love-bootloader.txt +32 -0
  28. package/examples/plugins/echo_tool.py +87 -0
  29. package/examples/queen-ava-feb.json +36 -0
  30. package/examples/souls/lumina.yaml +64 -0
  31. package/index.js +6 -5
  32. package/installer/build.py +124 -0
  33. package/openclaw-plugin/package.json +13 -0
  34. package/openclaw-plugin/src/index.ts +351 -0
  35. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  36. package/package.json +1 -1
  37. package/pyproject.toml +38 -2
  38. package/scripts/bump_version.py +141 -0
  39. package/scripts/check-updates.py +230 -0
  40. package/scripts/convert_blueprints_to_yaml.py +157 -0
  41. package/scripts/dev-install.sh +14 -0
  42. package/scripts/e2e-test.sh +193 -0
  43. package/scripts/install-bundle.sh +171 -0
  44. package/scripts/install.bat +2 -0
  45. package/scripts/install.ps1 +253 -0
  46. package/scripts/install.sh +185 -0
  47. package/scripts/mcp-serve.sh +69 -0
  48. package/scripts/mcp-server.bat +113 -0
  49. package/scripts/mcp-server.ps1 +116 -0
  50. package/scripts/mcp-server.sh +99 -0
  51. package/scripts/pull-models.sh +10 -0
  52. package/scripts/skcapstone +48 -0
  53. package/scripts/verify_install.sh +180 -0
  54. package/scripts/windows/install-tasks.ps1 +406 -0
  55. package/scripts/windows/skcapstone-task.xml +113 -0
  56. package/scripts/windows/uninstall-tasks.ps1 +117 -0
  57. package/skill.yaml +34 -0
  58. package/src/skcapstone/__init__.py +67 -2
  59. package/src/skcapstone/_cli_monolith.py +5916 -0
  60. package/src/skcapstone/_trustee_helpers.py +165 -0
  61. package/src/skcapstone/activity.py +105 -0
  62. package/src/skcapstone/agent_card.py +324 -0
  63. package/src/skcapstone/api.py +1935 -0
  64. package/src/skcapstone/archiver.py +340 -0
  65. package/src/skcapstone/auction.py +485 -0
  66. package/src/skcapstone/baby_agents.py +179 -0
  67. package/src/skcapstone/backup.py +345 -0
  68. package/src/skcapstone/blueprint_registry.py +357 -0
  69. package/src/skcapstone/blueprints/__init__.py +17 -0
  70. package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
  71. package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
  72. package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
  73. package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
  74. package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
  75. package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
  76. package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
  77. package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
  78. package/src/skcapstone/blueprints/registry.py +164 -0
  79. package/src/skcapstone/blueprints/schema.py +229 -0
  80. package/src/skcapstone/changelog.py +180 -0
  81. package/src/skcapstone/chat.py +769 -0
  82. package/src/skcapstone/claude_md.py +82 -0
  83. package/src/skcapstone/cli/__init__.py +144 -0
  84. package/src/skcapstone/cli/_common.py +88 -0
  85. package/src/skcapstone/cli/_validators.py +76 -0
  86. package/src/skcapstone/cli/agents.py +425 -0
  87. package/src/skcapstone/cli/agents_spawner.py +322 -0
  88. package/src/skcapstone/cli/agents_trustee.py +593 -0
  89. package/src/skcapstone/cli/alerts.py +248 -0
  90. package/src/skcapstone/cli/anchor.py +132 -0
  91. package/src/skcapstone/cli/archive_cmd.py +208 -0
  92. package/src/skcapstone/cli/backup.py +144 -0
  93. package/src/skcapstone/cli/bench.py +377 -0
  94. package/src/skcapstone/cli/benchmark.py +360 -0
  95. package/src/skcapstone/cli/capabilities_cmd.py +171 -0
  96. package/src/skcapstone/cli/card.py +151 -0
  97. package/src/skcapstone/cli/chat.py +584 -0
  98. package/src/skcapstone/cli/completions.py +64 -0
  99. package/src/skcapstone/cli/config_cmd.py +156 -0
  100. package/src/skcapstone/cli/consciousness.py +421 -0
  101. package/src/skcapstone/cli/context_cmd.py +142 -0
  102. package/src/skcapstone/cli/coord.py +194 -0
  103. package/src/skcapstone/cli/crush_cmd.py +170 -0
  104. package/src/skcapstone/cli/daemon.py +436 -0
  105. package/src/skcapstone/cli/errors_cmd.py +285 -0
  106. package/src/skcapstone/cli/export_cmd.py +156 -0
  107. package/src/skcapstone/cli/gtd.py +529 -0
  108. package/src/skcapstone/cli/housekeeping.py +81 -0
  109. package/src/skcapstone/cli/joule_cmd.py +627 -0
  110. package/src/skcapstone/cli/logs_cmd.py +194 -0
  111. package/src/skcapstone/cli/mcp_cmd.py +32 -0
  112. package/src/skcapstone/cli/memory.py +418 -0
  113. package/src/skcapstone/cli/metrics_cmd.py +136 -0
  114. package/src/skcapstone/cli/migrate.py +62 -0
  115. package/src/skcapstone/cli/mood_cmd.py +144 -0
  116. package/src/skcapstone/cli/mount.py +193 -0
  117. package/src/skcapstone/cli/notify.py +112 -0
  118. package/src/skcapstone/cli/peer.py +154 -0
  119. package/src/skcapstone/cli/peers_dir.py +122 -0
  120. package/src/skcapstone/cli/preflight_cmd.py +83 -0
  121. package/src/skcapstone/cli/profile_cmd.py +310 -0
  122. package/src/skcapstone/cli/record_cmd.py +238 -0
  123. package/src/skcapstone/cli/register_cmd.py +159 -0
  124. package/src/skcapstone/cli/search_cmd.py +156 -0
  125. package/src/skcapstone/cli/service_cmd.py +91 -0
  126. package/src/skcapstone/cli/session.py +127 -0
  127. package/src/skcapstone/cli/setup.py +240 -0
  128. package/src/skcapstone/cli/shell_cmd.py +43 -0
  129. package/src/skcapstone/cli/skills_cmd.py +168 -0
  130. package/src/skcapstone/cli/skseed.py +621 -0
  131. package/src/skcapstone/cli/soul.py +699 -0
  132. package/src/skcapstone/cli/status.py +935 -0
  133. package/src/skcapstone/cli/sync_cmd.py +301 -0
  134. package/src/skcapstone/cli/telegram.py +265 -0
  135. package/src/skcapstone/cli/test_cmd.py +234 -0
  136. package/src/skcapstone/cli/test_connection.py +253 -0
  137. package/src/skcapstone/cli/token.py +207 -0
  138. package/src/skcapstone/cli/trust.py +179 -0
  139. package/src/skcapstone/cli/upgrade_cmd.py +552 -0
  140. package/src/skcapstone/cli/usage_cmd.py +199 -0
  141. package/src/skcapstone/cli/version_cmd.py +162 -0
  142. package/src/skcapstone/cli/watch_cmd.py +342 -0
  143. package/src/skcapstone/client.py +428 -0
  144. package/src/skcapstone/cloud9_bridge.py +522 -0
  145. package/src/skcapstone/completions.py +163 -0
  146. package/src/skcapstone/config_validator.py +674 -0
  147. package/src/skcapstone/connectors/__init__.py +28 -0
  148. package/src/skcapstone/connectors/base.py +446 -0
  149. package/src/skcapstone/connectors/cursor.py +54 -0
  150. package/src/skcapstone/connectors/registry.py +254 -0
  151. package/src/skcapstone/connectors/terminal.py +152 -0
  152. package/src/skcapstone/connectors/vscode.py +60 -0
  153. package/src/skcapstone/consciousness_config.py +119 -0
  154. package/src/skcapstone/consciousness_loop.py +2051 -0
  155. package/src/skcapstone/context_loader.py +516 -0
  156. package/src/skcapstone/context_window.py +314 -0
  157. package/src/skcapstone/conversation_manager.py +238 -0
  158. package/src/skcapstone/conversation_store.py +230 -0
  159. package/src/skcapstone/conversation_summarizer.py +252 -0
  160. package/src/skcapstone/coord_federation.py +296 -0
  161. package/src/skcapstone/coordination.py +101 -7
  162. package/src/skcapstone/crush_integration.py +345 -0
  163. package/src/skcapstone/crush_shim.py +454 -0
  164. package/src/skcapstone/daemon.py +2494 -0
  165. package/src/skcapstone/dashboard.html +396 -0
  166. package/src/skcapstone/dashboard.py +481 -0
  167. package/src/skcapstone/data/model_profiles.yaml +88 -0
  168. package/src/skcapstone/defaults/__init__.py +55 -0
  169. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
  170. package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
  171. package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
  172. package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
  173. package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
  174. package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
  175. package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
  176. package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
  177. package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
  178. package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
  179. package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
  180. package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
  181. package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
  182. package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
  183. package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
  184. package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
  185. package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
  186. package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
  187. package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
  188. package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
  189. package/src/skcapstone/discovery.py +210 -19
  190. package/src/skcapstone/doctor.py +642 -0
  191. package/src/skcapstone/emotion_tracker.py +467 -0
  192. package/src/skcapstone/error_queue.py +405 -0
  193. package/src/skcapstone/export.py +447 -0
  194. package/src/skcapstone/fallback_tracker.py +186 -0
  195. package/src/skcapstone/file_transfer.py +512 -0
  196. package/src/skcapstone/fuse_mount.py +1156 -0
  197. package/src/skcapstone/gui_installer.py +591 -0
  198. package/src/skcapstone/heartbeat.py +611 -0
  199. package/src/skcapstone/housekeeping.py +298 -0
  200. package/src/skcapstone/install_wizard.py +941 -0
  201. package/src/skcapstone/kms.py +942 -0
  202. package/src/skcapstone/kms_scheduler.py +143 -0
  203. package/src/skcapstone/log_config.py +135 -0
  204. package/src/skcapstone/mcp_launcher.py +239 -0
  205. package/src/skcapstone/mcp_server.py +4700 -0
  206. package/src/skcapstone/mcp_tools/__init__.py +94 -0
  207. package/src/skcapstone/mcp_tools/_helpers.py +51 -0
  208. package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
  209. package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
  210. package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
  211. package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
  212. package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
  213. package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
  214. package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
  215. package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
  216. package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
  217. package/src/skcapstone/mcp_tools/did_tools.py +448 -0
  218. package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
  219. package/src/skcapstone/mcp_tools/file_tools.py +169 -0
  220. package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
  221. package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
  222. package/src/skcapstone/mcp_tools/health_tools.py +44 -0
  223. package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
  224. package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
  225. package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
  226. package/src/skcapstone/mcp_tools/model_tools.py +75 -0
  227. package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
  228. package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
  229. package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
  230. package/src/skcapstone/mcp_tools/security_tools.py +110 -0
  231. package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
  232. package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
  233. package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
  234. package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
  235. package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
  236. package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
  237. package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
  238. package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
  239. package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
  240. package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
  241. package/src/skcapstone/mdns_discovery.py +313 -0
  242. package/src/skcapstone/memory_adapter.py +333 -0
  243. package/src/skcapstone/memory_compressor.py +379 -0
  244. package/src/skcapstone/memory_curator.py +256 -0
  245. package/src/skcapstone/memory_engine.py +132 -13
  246. package/src/skcapstone/memory_fortress.py +529 -0
  247. package/src/skcapstone/memory_promoter.py +722 -0
  248. package/src/skcapstone/memory_verifier.py +260 -0
  249. package/src/skcapstone/message_crypto.py +215 -0
  250. package/src/skcapstone/metrics.py +832 -0
  251. package/src/skcapstone/migrate_memories.py +181 -0
  252. package/src/skcapstone/migrate_multi_agent.py +248 -0
  253. package/src/skcapstone/model_router.py +319 -0
  254. package/src/skcapstone/models.py +35 -4
  255. package/src/skcapstone/mood.py +344 -0
  256. package/src/skcapstone/notifications.py +380 -0
  257. package/src/skcapstone/onboard.py +901 -0
  258. package/src/skcapstone/peer_directory.py +324 -0
  259. package/src/skcapstone/peers.py +329 -0
  260. package/src/skcapstone/pillars/identity.py +84 -14
  261. package/src/skcapstone/pillars/memory.py +3 -1
  262. package/src/skcapstone/pillars/security.py +108 -15
  263. package/src/skcapstone/pillars/sync.py +78 -26
  264. package/src/skcapstone/pillars/trust.py +95 -33
  265. package/src/skcapstone/plugins.py +244 -0
  266. package/src/skcapstone/preflight.py +670 -0
  267. package/src/skcapstone/prompt_adapter.py +564 -0
  268. package/src/skcapstone/providers/__init__.py +13 -0
  269. package/src/skcapstone/providers/cloud.py +1061 -0
  270. package/src/skcapstone/providers/docker.py +759 -0
  271. package/src/skcapstone/providers/local.py +1193 -0
  272. package/src/skcapstone/providers/proxmox.py +447 -0
  273. package/src/skcapstone/pubsub.py +516 -0
  274. package/src/skcapstone/rate_limiter.py +119 -0
  275. package/src/skcapstone/register.py +241 -0
  276. package/src/skcapstone/registry_client.py +151 -0
  277. package/src/skcapstone/response_cache.py +194 -0
  278. package/src/skcapstone/response_scorer.py +225 -0
  279. package/src/skcapstone/runtime.py +89 -33
  280. package/src/skcapstone/scheduled_tasks.py +439 -0
  281. package/src/skcapstone/self_healing.py +341 -0
  282. package/src/skcapstone/service_health.py +228 -0
  283. package/src/skcapstone/session_capture.py +268 -0
  284. package/src/skcapstone/session_recorder.py +210 -0
  285. package/src/skcapstone/session_replayer.py +189 -0
  286. package/src/skcapstone/session_skills.py +263 -0
  287. package/src/skcapstone/shell.py +779 -0
  288. package/src/skcapstone/skills/__init__.py +1 -1
  289. package/src/skcapstone/skills/syncthing_setup.py +143 -41
  290. package/src/skcapstone/skjoule.py +861 -0
  291. package/src/skcapstone/snapshots.py +489 -0
  292. package/src/skcapstone/soul.py +1060 -0
  293. package/src/skcapstone/soul_switch.py +255 -0
  294. package/src/skcapstone/spawner.py +544 -0
  295. package/src/skcapstone/state_diff.py +401 -0
  296. package/src/skcapstone/summary.py +270 -0
  297. package/src/skcapstone/sync/backends.py +196 -2
  298. package/src/skcapstone/sync/engine.py +7 -5
  299. package/src/skcapstone/sync/models.py +4 -1
  300. package/src/skcapstone/sync/vault.py +356 -18
  301. package/src/skcapstone/sync_engine.py +363 -0
  302. package/src/skcapstone/sync_watcher.py +745 -0
  303. package/src/skcapstone/systemd.py +331 -0
  304. package/src/skcapstone/team_comms.py +476 -0
  305. package/src/skcapstone/team_engine.py +522 -0
  306. package/src/skcapstone/testrunner.py +300 -0
  307. package/src/skcapstone/tls.py +150 -0
  308. package/src/skcapstone/tokens.py +5 -5
  309. package/src/skcapstone/trust_calibration.py +202 -0
  310. package/src/skcapstone/trust_graph.py +449 -0
  311. package/src/skcapstone/trustee_monitor.py +385 -0
  312. package/src/skcapstone/trustee_ops.py +425 -0
  313. package/src/skcapstone/unified_search.py +421 -0
  314. package/src/skcapstone/uninstall_wizard.py +694 -0
  315. package/src/skcapstone/usage.py +331 -0
  316. package/src/skcapstone/version_check.py +148 -0
  317. package/src/skcapstone/warmth_anchor.py +333 -0
  318. package/src/skcapstone/whoami.py +294 -0
  319. package/systemd/skcapstone-api.socket +9 -0
  320. package/systemd/skcapstone-memory-compress.service +18 -0
  321. package/systemd/skcapstone-memory-compress.timer +11 -0
  322. package/systemd/skcapstone.service +36 -0
  323. package/systemd/skcapstone@.service +50 -0
  324. package/systemd/skcomm-heartbeat.service +18 -0
  325. package/systemd/skcomm-heartbeat.timer +12 -0
  326. package/systemd/skcomm-queue-drain.service +17 -0
  327. package/systemd/skcomm-queue-drain.timer +12 -0
  328. package/tests/conftest.py +13 -1
  329. package/tests/integration/__init__.py +1 -0
  330. package/tests/integration/test_consciousness_e2e.py +877 -0
  331. package/tests/integration/test_skills_registry.py +744 -0
  332. package/tests/test_agent_card.py +190 -0
  333. package/tests/test_agent_runtime.py +1283 -0
  334. package/tests/test_alerts_cmd.py +291 -0
  335. package/tests/test_archiver.py +498 -0
  336. package/tests/test_backup.py +254 -0
  337. package/tests/test_benchmark.py +366 -0
  338. package/tests/test_blueprints.py +457 -0
  339. package/tests/test_capabilities.py +257 -0
  340. package/tests/test_changelog.py +254 -0
  341. package/tests/test_chat.py +385 -0
  342. package/tests/test_claude_md.py +271 -0
  343. package/tests/test_cli_chat_llm.py +336 -0
  344. package/tests/test_cli_completions.py +390 -0
  345. package/tests/test_cli_init_reset.py +164 -0
  346. package/tests/test_cli_memory.py +208 -0
  347. package/tests/test_cli_profile.py +294 -0
  348. package/tests/test_cli_skills.py +223 -0
  349. package/tests/test_cli_status.py +395 -0
  350. package/tests/test_cli_test_cmd.py +206 -0
  351. package/tests/test_cli_test_connection.py +364 -0
  352. package/tests/test_cloud9_bridge.py +260 -0
  353. package/tests/test_cloud_provider.py +449 -0
  354. package/tests/test_cloud_providers.py +522 -0
  355. package/tests/test_completions.py +158 -0
  356. package/tests/test_component_manager.py +398 -0
  357. package/tests/test_config_reload.py +386 -0
  358. package/tests/test_config_validate.py +529 -0
  359. package/tests/test_consciousness_e2e.py +296 -0
  360. package/tests/test_consciousness_loop.py +1289 -0
  361. package/tests/test_context_loader.py +310 -0
  362. package/tests/test_conversation_api.py +306 -0
  363. package/tests/test_conversation_manager.py +381 -0
  364. package/tests/test_conversation_store.py +391 -0
  365. package/tests/test_conversation_summarizer.py +302 -0
  366. package/tests/test_cross_package.py +791 -0
  367. package/tests/test_crush_shim.py +519 -0
  368. package/tests/test_daemon.py +781 -0
  369. package/tests/test_daemon_shutdown.py +309 -0
  370. package/tests/test_dashboard.py +454 -0
  371. package/tests/test_discovery.py +200 -6
  372. package/tests/test_docker_provider.py +966 -0
  373. package/tests/test_doctor.py +257 -0
  374. package/tests/test_doctor_fix.py +351 -0
  375. package/tests/test_e2e_automated.py +292 -0
  376. package/tests/test_error_queue.py +404 -0
  377. package/tests/test_export.py +441 -0
  378. package/tests/test_fallback_tracker.py +219 -0
  379. package/tests/test_file_transfer.py +397 -0
  380. package/tests/test_fuse_mount.py +832 -0
  381. package/tests/test_health_loop.py +422 -0
  382. package/tests/test_heartbeat.py +354 -0
  383. package/tests/test_housekeeping.py +195 -0
  384. package/tests/test_identity_capauth.py +307 -0
  385. package/tests/test_identity_pillar.py +117 -0
  386. package/tests/test_install_wizard.py +68 -0
  387. package/tests/test_integration.py +325 -0
  388. package/tests/test_kms.py +495 -0
  389. package/tests/test_llm_providers.py +265 -0
  390. package/tests/test_local_provider.py +591 -0
  391. package/tests/test_log_config.py +199 -0
  392. package/tests/test_logs_cmd.py +287 -0
  393. package/tests/test_mcp_server.py +1909 -0
  394. package/tests/test_memory_adapter.py +339 -0
  395. package/tests/test_memory_curator.py +218 -0
  396. package/tests/test_memory_engine.py +6 -0
  397. package/tests/test_memory_fortress.py +571 -0
  398. package/tests/test_memory_pillar.py +119 -0
  399. package/tests/test_memory_promoter.py +445 -0
  400. package/tests/test_memory_verifier.py +420 -0
  401. package/tests/test_message_crypto.py +187 -0
  402. package/tests/test_metrics.py +632 -0
  403. package/tests/test_migrate_memories.py +464 -0
  404. package/tests/test_model_router.py +546 -0
  405. package/tests/test_mood.py +394 -0
  406. package/tests/test_multi_agent.py +269 -0
  407. package/tests/test_notifications.py +270 -0
  408. package/tests/test_onboard.py +500 -0
  409. package/tests/test_peer_directory.py +395 -0
  410. package/tests/test_peers.py +248 -0
  411. package/tests/test_pillars.py +87 -9
  412. package/tests/test_preflight.py +484 -0
  413. package/tests/test_prompt_adapter.py +331 -0
  414. package/tests/test_proxmox_provider.py +571 -0
  415. package/tests/test_pubsub.py +377 -0
  416. package/tests/test_rate_limiter.py +121 -0
  417. package/tests/test_registry_client.py +129 -0
  418. package/tests/test_response_cache.py +312 -0
  419. package/tests/test_response_scorer.py +294 -0
  420. package/tests/test_runtime.py +59 -0
  421. package/tests/test_scheduled_tasks.py +451 -0
  422. package/tests/test_security.py +250 -0
  423. package/tests/test_security_pillar.py +213 -0
  424. package/tests/test_self_healing.py +171 -0
  425. package/tests/test_session_capture.py +200 -0
  426. package/tests/test_session_recorder.py +360 -0
  427. package/tests/test_session_skills.py +235 -0
  428. package/tests/test_shell.py +210 -0
  429. package/tests/test_snapshots.py +549 -0
  430. package/tests/test_soul.py +984 -0
  431. package/tests/test_soul_swap.py +406 -0
  432. package/tests/test_spawner.py +211 -0
  433. package/tests/test_state_diff.py +173 -0
  434. package/tests/test_summary.py +135 -0
  435. package/tests/test_sync.py +315 -5
  436. package/tests/test_sync_backends.py +560 -0
  437. package/tests/test_sync_engine.py +482 -0
  438. package/tests/test_sync_pillar.py +344 -0
  439. package/tests/test_sync_pipeline.py +364 -0
  440. package/tests/test_sync_vault.py +581 -0
  441. package/tests/test_syncthing_setup.py +168 -22
  442. package/tests/test_systemd.py +323 -0
  443. package/tests/test_team_comms.py +408 -0
  444. package/tests/test_team_engine.py +397 -0
  445. package/tests/test_testrunner.py +238 -0
  446. package/tests/test_trust_calibration.py +204 -0
  447. package/tests/test_trust_graph.py +207 -0
  448. package/tests/test_trust_pillar.py +291 -0
  449. package/tests/test_trustee_cli.py +427 -0
  450. package/tests/test_trustee_cli_integration.py +325 -0
  451. package/tests/test_trustee_monitor.py +394 -0
  452. package/tests/test_trustee_ops.py +355 -0
  453. package/tests/test_unified_search.py +363 -0
  454. package/tests/test_uninstall_wizard.py +193 -0
  455. package/tests/test_usage.py +333 -0
  456. package/tests/test_version_cmd.py +355 -0
  457. package/tests/test_warmth_anchor.py +162 -0
  458. package/tests/test_whoami.py +245 -0
  459. package/tests/test_ws.py +311 -0
  460. package/.cursorrules +0 -33
  461. 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>&#9670; {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