prompt_objects 0.1.0

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 (117) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +108 -0
  3. data/Gemfile +10 -0
  4. data/Gemfile.lock +231 -0
  5. data/IMPLEMENTATION_PLAN.md +1073 -0
  6. data/LICENSE +21 -0
  7. data/README.md +73 -0
  8. data/Rakefile +27 -0
  9. data/design-doc-v2.md +1232 -0
  10. data/exe/prompt_objects +572 -0
  11. data/exe/prompt_objects_mcp +34 -0
  12. data/frontend/.gitignore +3 -0
  13. data/frontend/index.html +13 -0
  14. data/frontend/package-lock.json +4417 -0
  15. data/frontend/package.json +32 -0
  16. data/frontend/postcss.config.js +6 -0
  17. data/frontend/src/App.tsx +95 -0
  18. data/frontend/src/components/CapabilitiesPanel.tsx +44 -0
  19. data/frontend/src/components/ChatPanel.tsx +251 -0
  20. data/frontend/src/components/Dashboard.tsx +83 -0
  21. data/frontend/src/components/Header.tsx +141 -0
  22. data/frontend/src/components/MarkdownMessage.tsx +153 -0
  23. data/frontend/src/components/MessageBus.tsx +55 -0
  24. data/frontend/src/components/ModelSelector.tsx +112 -0
  25. data/frontend/src/components/NotificationPanel.tsx +134 -0
  26. data/frontend/src/components/POCard.tsx +56 -0
  27. data/frontend/src/components/PODetail.tsx +117 -0
  28. data/frontend/src/components/PromptPanel.tsx +51 -0
  29. data/frontend/src/components/SessionsPanel.tsx +174 -0
  30. data/frontend/src/components/ThreadsSidebar.tsx +119 -0
  31. data/frontend/src/components/index.ts +11 -0
  32. data/frontend/src/hooks/useWebSocket.ts +363 -0
  33. data/frontend/src/index.css +37 -0
  34. data/frontend/src/main.tsx +10 -0
  35. data/frontend/src/store/index.ts +246 -0
  36. data/frontend/src/types/index.ts +146 -0
  37. data/frontend/tailwind.config.js +25 -0
  38. data/frontend/tsconfig.json +30 -0
  39. data/frontend/vite.config.ts +29 -0
  40. data/lib/prompt_objects/capability.rb +46 -0
  41. data/lib/prompt_objects/cli.rb +431 -0
  42. data/lib/prompt_objects/connectors/base.rb +73 -0
  43. data/lib/prompt_objects/connectors/mcp.rb +524 -0
  44. data/lib/prompt_objects/environment/exporter.rb +83 -0
  45. data/lib/prompt_objects/environment/git.rb +118 -0
  46. data/lib/prompt_objects/environment/importer.rb +159 -0
  47. data/lib/prompt_objects/environment/manager.rb +401 -0
  48. data/lib/prompt_objects/environment/manifest.rb +218 -0
  49. data/lib/prompt_objects/environment.rb +283 -0
  50. data/lib/prompt_objects/human_queue.rb +144 -0
  51. data/lib/prompt_objects/llm/anthropic_adapter.rb +137 -0
  52. data/lib/prompt_objects/llm/factory.rb +84 -0
  53. data/lib/prompt_objects/llm/gemini_adapter.rb +209 -0
  54. data/lib/prompt_objects/llm/openai_adapter.rb +104 -0
  55. data/lib/prompt_objects/llm/response.rb +61 -0
  56. data/lib/prompt_objects/loader.rb +32 -0
  57. data/lib/prompt_objects/mcp/server.rb +167 -0
  58. data/lib/prompt_objects/mcp/tools/get_conversation.rb +60 -0
  59. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +54 -0
  60. data/lib/prompt_objects/mcp/tools/inspect_po.rb +73 -0
  61. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +37 -0
  62. data/lib/prompt_objects/mcp/tools/respond_to_request.rb +68 -0
  63. data/lib/prompt_objects/mcp/tools/send_message.rb +71 -0
  64. data/lib/prompt_objects/message_bus.rb +97 -0
  65. data/lib/prompt_objects/primitive.rb +13 -0
  66. data/lib/prompt_objects/primitives/http_get.rb +72 -0
  67. data/lib/prompt_objects/primitives/list_files.rb +95 -0
  68. data/lib/prompt_objects/primitives/read_file.rb +81 -0
  69. data/lib/prompt_objects/primitives/write_file.rb +73 -0
  70. data/lib/prompt_objects/prompt_object.rb +415 -0
  71. data/lib/prompt_objects/registry.rb +88 -0
  72. data/lib/prompt_objects/server/api/routes.rb +297 -0
  73. data/lib/prompt_objects/server/app.rb +174 -0
  74. data/lib/prompt_objects/server/file_watcher.rb +113 -0
  75. data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +77 -0
  76. data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +1 -0
  77. data/lib/prompt_objects/server/public/index.html +14 -0
  78. data/lib/prompt_objects/server/websocket_handler.rb +619 -0
  79. data/lib/prompt_objects/server.rb +166 -0
  80. data/lib/prompt_objects/session/store.rb +826 -0
  81. data/lib/prompt_objects/universal/add_capability.rb +74 -0
  82. data/lib/prompt_objects/universal/add_primitive.rb +113 -0
  83. data/lib/prompt_objects/universal/ask_human.rb +109 -0
  84. data/lib/prompt_objects/universal/create_capability.rb +219 -0
  85. data/lib/prompt_objects/universal/create_primitive.rb +170 -0
  86. data/lib/prompt_objects/universal/list_capabilities.rb +55 -0
  87. data/lib/prompt_objects/universal/list_primitives.rb +145 -0
  88. data/lib/prompt_objects/universal/modify_primitive.rb +180 -0
  89. data/lib/prompt_objects/universal/request_primitive.rb +287 -0
  90. data/lib/prompt_objects/universal/think.rb +41 -0
  91. data/lib/prompt_objects/universal/verify_primitive.rb +173 -0
  92. data/lib/prompt_objects.rb +62 -0
  93. data/objects/coordinator.md +48 -0
  94. data/objects/greeter.md +30 -0
  95. data/objects/reader.md +33 -0
  96. data/prompt_objects.gemspec +50 -0
  97. data/templates/basic/.gitignore +2 -0
  98. data/templates/basic/manifest.yml +7 -0
  99. data/templates/basic/objects/basic.md +32 -0
  100. data/templates/developer/.gitignore +5 -0
  101. data/templates/developer/manifest.yml +17 -0
  102. data/templates/developer/objects/code_reviewer.md +33 -0
  103. data/templates/developer/objects/coordinator.md +39 -0
  104. data/templates/developer/objects/debugger.md +35 -0
  105. data/templates/empty/.gitignore +5 -0
  106. data/templates/empty/manifest.yml +14 -0
  107. data/templates/empty/objects/.gitkeep +0 -0
  108. data/templates/empty/objects/assistant.md +41 -0
  109. data/templates/minimal/.gitignore +5 -0
  110. data/templates/minimal/manifest.yml +7 -0
  111. data/templates/minimal/objects/assistant.md +41 -0
  112. data/templates/writer/.gitignore +5 -0
  113. data/templates/writer/manifest.yml +17 -0
  114. data/templates/writer/objects/coordinator.md +33 -0
  115. data/templates/writer/objects/editor.md +33 -0
  116. data/templates/writer/objects/researcher.md +34 -0
  117. metadata +343 -0
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.-right-1{right:-.25rem}.-top-1{top:-.25rem}.bottom-4{bottom:1rem}.left-2{left:.5rem}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-16{top:4rem}.top-full{top:100%}.z-10{z-index:10}.z-50{z-index:50}.my-3{margin-top:.75rem;margin-bottom:.75rem}.my-4{margin-top:1rem;margin-bottom:1rem}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-64{max-height:16rem}.max-h-\[60vh\]{max-height:60vh}.w-1\.5{width:.375rem}.w-2{width:.5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-\[80\%\]{max-width:80%}.max-w-none{max-width:none}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-row-reverse{flex-direction:row-reverse}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-b-0{border-bottom-width:0px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-po-accent{--tw-border-opacity: 1;border-color:rgb(124 58 237 / var(--tw-border-opacity, 1))}.border-po-border{--tw-border-opacity: 1;border-color:rgb(45 45 68 / var(--tw-border-opacity, 1))}.border-po-border\/50{border-color:#2d2d4480}.border-po-warning\/30{border-color:#f59e0b4d}.border-transparent{border-color:transparent}.bg-blue-600\/30{background-color:#2563eb4d}.bg-gray-500{--tw-bg-opacity: 1;background-color:rgb(107 114 128 / var(--tw-bg-opacity, 1))}.bg-gray-600\/30{background-color:#4b55634d}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-po-accent{--tw-bg-opacity: 1;background-color:rgb(124 58 237 / var(--tw-bg-opacity, 1))}.bg-po-accent\/20{background-color:#7c3aed33}.bg-po-bg{--tw-bg-opacity: 1;background-color:rgb(15 15 26 / var(--tw-bg-opacity, 1))}.bg-po-bg\/50{background-color:#0f0f1a80}.bg-po-bg\/80{background-color:#0f0f1acc}.bg-po-border{--tw-bg-opacity: 1;background-color:rgb(45 45 68 / var(--tw-bg-opacity, 1))}.bg-po-surface{--tw-bg-opacity: 1;background-color:rgb(26 26 46 / var(--tw-bg-opacity, 1))}.bg-po-warning{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-po-warning\/10{background-color:#f59e0b1a}.bg-purple-600\/30{background-color:#9333ea4d}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-relaxed{line-height:1.625}.tracking-wide{letter-spacing:.025em}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-po-accent{--tw-text-opacity: 1;color:rgb(124 58 237 / var(--tw-text-opacity, 1))}.text-po-warning{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(107 114 128 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity: 1;color:rgb(107 114 128 / var(--tw-placeholder-opacity, 1))}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}html{color-scheme:dark}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{--tw-bg-opacity: 1;background-color:rgb(15 15 26 / var(--tw-bg-opacity, 1))}::-webkit-scrollbar-thumb{border-radius:9999px;--tw-bg-opacity: 1;background-color:rgb(45 45 68 / var(--tw-bg-opacity, 1))}::-webkit-scrollbar-thumb:hover{--tw-bg-opacity: 1;background-color:rgb(124 58 237 / var(--tw-bg-opacity, 1))}.first\:mt-0:first-child{margin-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-0:last-child{border-width:0px}.hover\:border-po-accent:hover{--tw-border-opacity: 1;border-color:rgb(124 58 237 / var(--tw-border-opacity, 1))}.hover\:border-po-accent\/50:hover{border-color:#7c3aed80}.hover\:bg-po-accent\/50:hover{background-color:#7c3aed80}.hover\:bg-po-accent\/80:hover{background-color:#7c3aedcc}.hover\:bg-po-bg:hover{--tw-bg-opacity: 1;background-color:rgb(15 15 26 / var(--tw-bg-opacity, 1))}.hover\:bg-po-bg\/70:hover{background-color:#0f0f1ab3}.hover\:bg-po-border:hover{--tw-bg-opacity: 1;background-color:rgb(45 45 68 / var(--tw-bg-opacity, 1))}.hover\:bg-po-surface:hover{--tw-bg-opacity: 1;background-color:rgb(26 26 46 / var(--tw-bg-opacity, 1))}.hover\:bg-po-warning\/20:hover{background-color:#f59e0b33}.hover\:text-po-accent:hover{--tw-text-opacity: 1;color:rgb(124 58 237 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-po-accent:focus{--tw-border-opacity: 1;border-color:rgb(124 58 237 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:text-po-accent{--tw-text-opacity: 1;color:rgb(124 58 237 / var(--tw-text-opacity, 1))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>PromptObjects</title>
8
+ <script type="module" crossorigin src="/assets/index-2acS2FYZ.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DXU5uRXQ.css">
10
+ </head>
11
+ <body class="bg-po-bg text-gray-100">
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1,619 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "async"
5
+
6
+ module PromptObjects
7
+ module Server
8
+ # Handles WebSocket connections for real-time communication with the frontend.
9
+ # Subscribes to MessageBus for state updates and handles client messages.
10
+ #
11
+ # == Real-time UI Feedback Pattern ==
12
+ #
13
+ # IMPORTANT: All user actions must provide immediate visual feedback BEFORE
14
+ # async operations complete. This makes the UI feel "alive" and responsive.
15
+ #
16
+ # When implementing new features, follow this pattern:
17
+ #
18
+ # 1. SWITCH CONTEXT FIRST
19
+ # When creating/switching threads, sessions, etc., send the context switch
20
+ # message FIRST so the frontend displays the new context immediately.
21
+ # Example: thread_created → frontend switches to new thread
22
+ #
23
+ # 2. UPDATE NAVIGATION
24
+ # Send updated lists (sessions, threads) immediately so sidebars reflect
25
+ # the change without waiting for async work to complete.
26
+ # Example: po_state with sessions list → ThreadsSidebar shows new thread
27
+ #
28
+ # 3. SHOW USER INPUT
29
+ # Send the user's input back immediately so they see it in the UI.
30
+ # Don't wait for the AI to respond before showing what the user typed.
31
+ # Example: session_updated with user message → chat shows "You: ..."
32
+ #
33
+ # 4. SHOW PROGRESS
34
+ # Update status indicators during work (thinking, calling_tool, etc.).
35
+ # Use streaming for incremental content when supported.
36
+ # Example: po_state status "thinking" → spinner/animation shown
37
+ #
38
+ # 5. CONFIRM COMPLETION
39
+ # Send final authoritative state after async work completes.
40
+ # Example: session_updated with full messages → final chat state
41
+ #
42
+ # Message flow for "send message with new thread":
43
+ # thread_created → switch to new thread
44
+ # po_state (sessions) → update sidebar
45
+ # session_updated → show user message
46
+ # po_state (thinking) → show progress indicator
47
+ # [stream chunks] → incremental AI response
48
+ # po_response → complete AI response
49
+ # session_updated → final messages
50
+ # po_state (idle) → clear progress indicator
51
+ class WebSocketHandler
52
+ def initialize(runtime:, connection:, app: nil)
53
+ @runtime = runtime
54
+ @connection = connection
55
+ @app = app
56
+ @subscribed = false
57
+ @bus_subscription = nil
58
+ end
59
+
60
+ def run
61
+ subscribe_to_bus
62
+ send_initial_state
63
+ read_loop
64
+ ensure
65
+ unsubscribe_from_bus
66
+ end
67
+
68
+ # Send a message to this client (public for broadcasting).
69
+ def send_message(data)
70
+ json = JSON.generate(data)
71
+ @connection.write(json)
72
+ @connection.flush
73
+ rescue => e
74
+ puts "WebSocket write error: #{e.message}" if ENV["DEBUG"]
75
+ end
76
+
77
+ private
78
+
79
+ # === MessageBus Integration ===
80
+
81
+ def subscribe_to_bus
82
+ @bus_subscription = ->(entry) { on_bus_message(entry) }
83
+ @runtime.bus.subscribe(&@bus_subscription)
84
+
85
+ # Subscribe to HumanQueue for notification events
86
+ @human_queue_subscription = ->(event, request) { on_human_queue_event(event, request) }
87
+ @runtime.human_queue.subscribe(&@human_queue_subscription)
88
+
89
+ @subscribed = true
90
+ end
91
+
92
+ def unsubscribe_from_bus
93
+ return unless @subscribed
94
+
95
+ @runtime.bus.unsubscribe(@bus_subscription) if @bus_subscription
96
+ @runtime.human_queue.unsubscribe(@human_queue_subscription) if @human_queue_subscription
97
+
98
+ @subscribed = false
99
+ end
100
+
101
+ def on_human_queue_event(event, request)
102
+ case event
103
+ when :added
104
+ send_message(
105
+ type: "notification",
106
+ payload: request_to_hash(request)
107
+ )
108
+ when :resolved
109
+ send_message(
110
+ type: "notification_resolved",
111
+ payload: { id: request.id }
112
+ )
113
+ end
114
+ rescue => e
115
+ puts "WebSocket notification error: #{e.message}" if ENV["DEBUG"]
116
+ end
117
+
118
+ def on_bus_message(entry)
119
+ send_message(
120
+ type: "bus_message",
121
+ payload: {
122
+ from: entry[:from],
123
+ to: entry[:to],
124
+ content: entry[:message],
125
+ timestamp: entry[:timestamp].iso8601
126
+ }
127
+ )
128
+ rescue => e
129
+ # Connection may be closed, ignore errors
130
+ puts "WebSocket send error: #{e.message}" if ENV["DEBUG"]
131
+ end
132
+
133
+ # === Initial State ===
134
+
135
+ def send_initial_state
136
+ # Send environment info
137
+ send_message(
138
+ type: "environment",
139
+ payload: {
140
+ name: @runtime.name,
141
+ path: @runtime.env_path,
142
+ po_count: @runtime.registry.prompt_objects.size,
143
+ primitive_count: @runtime.registry.primitives.size
144
+ }
145
+ )
146
+
147
+ # Send LLM config
148
+ handle_get_llm_config
149
+
150
+ # Send state of all POs
151
+ # Always send "idle" status for initial state - from this connection's
152
+ # perspective, no work is pending (even if another connection has a request)
153
+ @runtime.registry.prompt_objects.each do |po|
154
+ send_message(
155
+ type: "po_state",
156
+ payload: {
157
+ name: po.name,
158
+ state: po_state_hash(po).merge(status: "idle")
159
+ }
160
+ )
161
+ end
162
+
163
+ # Send pending human requests (notifications)
164
+ @runtime.human_queue.all_pending.each do |request|
165
+ send_message(
166
+ type: "notification",
167
+ payload: request_to_hash(request)
168
+ )
169
+ end
170
+
171
+ # Send recent bus messages (last 50)
172
+ @runtime.bus.log.last(50).each do |entry|
173
+ send_message(
174
+ type: "bus_message",
175
+ payload: {
176
+ from: entry[:from],
177
+ to: entry[:to],
178
+ content: entry[:message],
179
+ timestamp: entry[:timestamp].iso8601
180
+ }
181
+ )
182
+ end
183
+ end
184
+
185
+ # === Client Message Handling ===
186
+
187
+ def read_loop
188
+ while (message = @connection.read)
189
+ handle_client_message(message)
190
+ end
191
+ rescue Async::Stop, EOFError, IOError
192
+ # Connection closed, exit gracefully
193
+ rescue => e
194
+ puts "WebSocket error: #{e.message}"
195
+ puts e.backtrace.first(5).join("\n") if ENV["DEBUG"]
196
+ end
197
+
198
+ def handle_client_message(raw_message)
199
+ # raw_message is a Protocol::WebSocket::TextMessage
200
+ data = raw_message.respond_to?(:buffer) ? raw_message.buffer : raw_message.to_s
201
+ message = JSON.parse(data)
202
+
203
+ case message["type"]
204
+ when "send_message"
205
+ handle_send_message(message["payload"])
206
+ when "respond_to_notification"
207
+ handle_notification_response(message["payload"])
208
+ when "update_po"
209
+ handle_update_po(message["payload"])
210
+ when "create_session"
211
+ handle_create_session(message["payload"])
212
+ when "switch_session"
213
+ handle_switch_session(message["payload"])
214
+ when "create_thread"
215
+ handle_create_thread(message["payload"])
216
+ when "get_thread_tree"
217
+ handle_get_thread_tree(message["payload"])
218
+ when "get_llm_config"
219
+ handle_get_llm_config
220
+ when "switch_llm"
221
+ handle_switch_llm(message["payload"])
222
+ when "ping"
223
+ send_message(type: "pong", payload: {})
224
+ else
225
+ send_error("Unknown message type: #{message['type']}")
226
+ end
227
+ rescue JSON::ParserError => e
228
+ send_error("Invalid JSON: #{e.message}")
229
+ rescue => e
230
+ send_error("Error: #{e.message}")
231
+ puts "Handler error: #{e.message}"
232
+ puts e.backtrace.first(5).join("\n") if ENV["DEBUG"]
233
+ end
234
+
235
+ def handle_send_message(payload)
236
+ po_name = payload["target"]
237
+ content = payload["content"]
238
+ new_thread = payload["new_thread"] # If true, create a new thread first
239
+
240
+ po = @runtime.registry.get(po_name)
241
+
242
+ unless po.is_a?(PromptObject)
243
+ send_error("Unknown prompt object: #{po_name}")
244
+ return
245
+ end
246
+
247
+ # If new_thread requested, create one first
248
+ if new_thread
249
+ thread_id = po.new_thread
250
+ # Notify client of new thread immediately so they see it
251
+ send_message(
252
+ type: "thread_created",
253
+ payload: {
254
+ target: po_name,
255
+ thread_id: thread_id,
256
+ thread_type: "root"
257
+ }
258
+ )
259
+ # Also send updated sessions list so ThreadsSidebar shows it immediately
260
+ send_message(
261
+ type: "po_state",
262
+ payload: {
263
+ name: po_name,
264
+ state: { sessions: po.list_sessions.map { |s| session_summary(s) } }
265
+ }
266
+ )
267
+ end
268
+
269
+ # Capture the session_id at request start so response goes to correct session
270
+ request_session_id = po.session_id
271
+
272
+ # Update PO state to working AND send the user message for immediate UI feedback
273
+ send_message(
274
+ type: "po_state",
275
+ payload: { name: po_name, state: { status: "thinking" } }
276
+ )
277
+
278
+ # Send immediate session update showing the user's message
279
+ # This gives instant feedback before the AI responds
280
+ # Include existing messages so we don't clear the chat when continuing a thread
281
+ existing_messages = session_messages(po, request_session_id)
282
+ new_user_message = { role: "user", content: content, from: "human" }
283
+ send_message(
284
+ type: "session_updated",
285
+ payload: {
286
+ target: po_name,
287
+ session_id: request_session_id,
288
+ messages: existing_messages + [new_user_message]
289
+ }
290
+ )
291
+
292
+ # Run in async context
293
+ Async do
294
+ # Use tui_mode: true so ask_human uses HumanQueue (blocking) instead of REPL mode
295
+ context = @runtime.context(tui_mode: true)
296
+ context.current_capability = "human"
297
+
298
+ begin
299
+ # TODO: Add streaming support to receive()
300
+ # For now, just get the full response
301
+ response = po.receive(content, context: context)
302
+
303
+ # Auto-name the thread if it doesn't have a name yet
304
+ auto_name_thread_if_needed(po, request_session_id, content)
305
+
306
+ # Send the complete response with session_id for correct routing
307
+ send_message(
308
+ type: "po_response",
309
+ payload: {
310
+ target: po_name,
311
+ session_id: request_session_id,
312
+ content: response
313
+ }
314
+ )
315
+
316
+ # Send session update for the session where messages were added
317
+ # This ensures the correct session is updated even if user switched
318
+ if request_session_id
319
+ send_message(
320
+ type: "session_updated",
321
+ payload: {
322
+ target: po_name,
323
+ session_id: request_session_id,
324
+ messages: session_messages(po, request_session_id)
325
+ }
326
+ )
327
+ end
328
+
329
+ # Also send updated sessions list (for thread panel)
330
+ send_message(
331
+ type: "po_state",
332
+ payload: {
333
+ name: po_name,
334
+ state: { sessions: po.list_sessions.map { |s| session_summary(s) } }
335
+ }
336
+ )
337
+ rescue => e
338
+ send_error("Error from #{po_name}: #{e.message}")
339
+ ensure
340
+ # Update PO state back to idle (status only, not session data)
341
+ send_message(
342
+ type: "po_state",
343
+ payload: { name: po_name, state: { status: "idle" } }
344
+ )
345
+ end
346
+ end
347
+ end
348
+
349
+ # Auto-name a thread based on the user's first message if it doesn't have a name
350
+ def auto_name_thread_if_needed(po, session_id, user_message)
351
+ return unless @runtime.session_store && session_id
352
+
353
+ session = @runtime.session_store.get_session(session_id)
354
+ return if session.nil? || session[:name] # Already has a name
355
+
356
+ @runtime.session_store.auto_name_thread(session_id, user_message)
357
+ end
358
+
359
+ def handle_notification_response(payload)
360
+ request_id = payload["id"]
361
+ response = payload["response"]
362
+
363
+ @runtime.human_queue.respond(request_id, response)
364
+
365
+ send_message(
366
+ type: "notification_resolved",
367
+ payload: { id: request_id }
368
+ )
369
+ end
370
+
371
+ def handle_update_po(payload)
372
+ po_name = payload["name"]
373
+ updates = payload["updates"]
374
+
375
+ po = @runtime.registry.get(po_name)
376
+ return send_error("Unknown prompt object: #{po_name}") unless po.is_a?(PromptObject)
377
+
378
+ # TODO: Implement PO updates (capabilities, etc.)
379
+ send_message(
380
+ type: "po_updated",
381
+ payload: { name: po_name }
382
+ )
383
+ end
384
+
385
+ def handle_create_session(payload)
386
+ po_name = payload["target"]
387
+ session_name = payload["name"]
388
+
389
+ po = @runtime.registry.get(po_name)
390
+ return send_error("Unknown prompt object: #{po_name}") unless po.is_a?(PromptObject)
391
+
392
+ session_id = po.new_session(name: session_name)
393
+
394
+ send_message(
395
+ type: "session_created",
396
+ payload: {
397
+ target: po_name,
398
+ session_id: session_id,
399
+ name: session_name
400
+ }
401
+ )
402
+
403
+ # Also send updated PO state
404
+ send_message(
405
+ type: "po_state",
406
+ payload: { name: po_name, state: po_state_hash(po) }
407
+ )
408
+ end
409
+
410
+ def handle_switch_session(payload)
411
+ po_name = payload["target"]
412
+ session_id = payload["session_id"]
413
+
414
+ po = @runtime.registry.get(po_name)
415
+ return send_error("Unknown prompt object: #{po_name}") unless po.is_a?(PromptObject)
416
+
417
+ if po.switch_session(session_id)
418
+ send_message(
419
+ type: "session_switched",
420
+ payload: { target: po_name, session_id: session_id }
421
+ )
422
+
423
+ # Send updated PO state with new session's messages
424
+ send_message(
425
+ type: "po_state",
426
+ payload: { name: po_name, state: po_state_hash(po) }
427
+ )
428
+ else
429
+ send_error("Could not switch to session: #{session_id}")
430
+ end
431
+ end
432
+
433
+ def handle_create_thread(payload)
434
+ po_name = payload["target"]
435
+ thread_name = payload["name"]
436
+ thread_type = payload["thread_type"] || "root"
437
+
438
+ po = @runtime.registry.get(po_name)
439
+ return send_error("Unknown prompt object: #{po_name}") unless po.is_a?(PromptObject)
440
+
441
+ thread_id = po.new_thread(name: thread_name)
442
+
443
+ send_message(
444
+ type: "thread_created",
445
+ payload: {
446
+ target: po_name,
447
+ thread_id: thread_id,
448
+ name: thread_name,
449
+ thread_type: thread_type
450
+ }
451
+ )
452
+
453
+ # Also send updated PO state
454
+ send_message(
455
+ type: "po_state",
456
+ payload: { name: po_name, state: po_state_hash(po) }
457
+ )
458
+ end
459
+
460
+ def handle_get_thread_tree(payload)
461
+ session_id = payload["session_id"]
462
+ return send_error("Session ID required") unless session_id
463
+ return send_error("No session store available") unless @runtime.session_store
464
+
465
+ tree = @runtime.session_store.get_thread_tree(session_id)
466
+ return send_error("Session not found: #{session_id}") unless tree
467
+
468
+ send_message(
469
+ type: "thread_tree",
470
+ payload: { tree: serialize_thread_tree(tree) }
471
+ )
472
+ end
473
+
474
+ def handle_get_llm_config
475
+ config = @runtime.llm_config
476
+
477
+ # Get models for each provider
478
+ providers_info = LLM::Factory.providers.map do |provider|
479
+ info = LLM::Factory.provider_info(provider)
480
+ {
481
+ name: provider,
482
+ models: info[:models],
483
+ default_model: info[:default_model],
484
+ available: LLM::Factory.available_providers[provider]
485
+ }
486
+ end
487
+
488
+ send_message(
489
+ type: "llm_config",
490
+ payload: {
491
+ current_provider: config[:provider],
492
+ current_model: config[:model],
493
+ providers: providers_info
494
+ }
495
+ )
496
+ end
497
+
498
+ def handle_switch_llm(payload)
499
+ provider = payload["provider"]
500
+ model = payload["model"]
501
+
502
+ begin
503
+ result = @runtime.switch_llm(provider: provider, model: model)
504
+
505
+ send_message(
506
+ type: "llm_switched",
507
+ payload: {
508
+ provider: result[:provider],
509
+ model: result[:model]
510
+ }
511
+ )
512
+
513
+ # Broadcast to all connected clients via app
514
+ @app&.broadcast(
515
+ type: "llm_switched",
516
+ payload: {
517
+ provider: result[:provider],
518
+ model: result[:model]
519
+ }
520
+ )
521
+ rescue PromptObjects::Error => e
522
+ send_error("Failed to switch LLM: #{e.message}")
523
+ end
524
+ end
525
+
526
+ # === Helpers ===
527
+
528
+ def send_error(message)
529
+ send_message(type: "error", payload: { message: message })
530
+ end
531
+
532
+ def po_state_hash(po)
533
+ {
534
+ status: po.instance_variable_get(:@state) || "idle",
535
+ description: po.description,
536
+ capabilities: po.config["capabilities"] || [],
537
+ current_session: current_session_hash(po),
538
+ sessions: po.list_sessions.map { |s| session_summary(s) },
539
+ # Include full prompt for inspection
540
+ prompt: po.body,
541
+ config: po.config
542
+ }
543
+ end
544
+
545
+ def current_session_hash(po)
546
+ return nil unless po.session_id
547
+
548
+ {
549
+ id: po.session_id,
550
+ messages: po.history.map { |m| message_to_hash(m) }
551
+ }
552
+ end
553
+
554
+ # Get messages for a specific session (may not be current session)
555
+ def session_messages(po, session_id)
556
+ return [] unless @runtime.session_store
557
+
558
+ messages = @runtime.session_store.get_messages(session_id)
559
+ messages.map { |m| message_to_hash(m) }
560
+ end
561
+
562
+ def session_summary(session)
563
+ {
564
+ id: session[:id],
565
+ name: session[:name],
566
+ message_count: session[:message_count] || 0,
567
+ updated_at: session[:updated_at]&.iso8601,
568
+ # Thread fields
569
+ parent_session_id: session[:parent_session_id],
570
+ parent_po: session[:parent_po],
571
+ thread_type: session[:thread_type] || "root"
572
+ }
573
+ end
574
+
575
+ # Recursively serialize a thread tree for JSON
576
+ def serialize_thread_tree(tree)
577
+ return nil unless tree
578
+
579
+ {
580
+ session: session_summary(tree[:session]),
581
+ children: (tree[:children] || []).map { |child| serialize_thread_tree(child) }
582
+ }
583
+ end
584
+
585
+ def message_to_hash(msg)
586
+ case msg[:role]
587
+ when :user
588
+ { role: "user", content: msg[:content], from: msg[:from] }
589
+ when :assistant
590
+ hash = { role: "assistant", content: msg[:content] }
591
+ if msg[:tool_calls]
592
+ hash[:tool_calls] = msg[:tool_calls].map do |tc|
593
+ # Handle both ToolCall objects and Hashes
594
+ tc_id = tc.respond_to?(:id) ? tc.id : (tc[:id] || tc["id"])
595
+ tc_name = tc.respond_to?(:name) ? tc.name : (tc[:name] || tc["name"])
596
+ tc_args = tc.respond_to?(:arguments) ? tc.arguments : (tc[:arguments] || tc["arguments"] || {})
597
+ { id: tc_id, name: tc_name, arguments: tc_args }
598
+ end
599
+ end
600
+ hash
601
+ when :tool
602
+ { role: "tool", results: msg[:results] }
603
+ else
604
+ { role: msg[:role].to_s, content: msg[:content] }
605
+ end
606
+ end
607
+
608
+ def request_to_hash(request)
609
+ {
610
+ id: request.id,
611
+ po_name: request.capability, # capability is the PO name
612
+ type: "ask_human", # hardcode type since it's always ask_human
613
+ message: request.question, # question is the message
614
+ options: request.options || []
615
+ }
616
+ end
617
+ end
618
+ end
619
+ end