prompt_objects 0.5.0 → 0.7.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 (292) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +142 -0
  3. data/README.md +211 -19
  4. data/exe/poop +5 -0
  5. data/exe/prompt_objects +1 -1108
  6. data/exe/prompt_objects_mcp +5 -3
  7. data/lib/prompt_objects/capability.rb +33 -0
  8. data/lib/prompt_objects/cli/diagnostics_command.rb +69 -0
  9. data/lib/prompt_objects/cli/env_command.rb +435 -0
  10. data/lib/prompt_objects/cli/events_command.rb +145 -0
  11. data/lib/prompt_objects/cli/explore_command.rb +155 -0
  12. data/lib/prompt_objects/cli/helpers.rb +91 -0
  13. data/lib/prompt_objects/cli/message_command.rb +216 -0
  14. data/lib/prompt_objects/cli/repl_command.rb +366 -0
  15. data/lib/prompt_objects/cli/serve_command.rb +162 -0
  16. data/lib/prompt_objects/cli.rb +64 -422
  17. data/lib/prompt_objects/connectors/base.rb +16 -29
  18. data/lib/prompt_objects/connectors/mcp.rb +82 -98
  19. data/lib/prompt_objects/domain/records.rb +45 -0
  20. data/lib/prompt_objects/environment/exporter.rb +5 -4
  21. data/lib/prompt_objects/environment/git.rb +21 -1
  22. data/lib/prompt_objects/environment/importer.rb +5 -4
  23. data/lib/prompt_objects/environment/manager.rb +44 -6
  24. data/lib/prompt_objects/environment/manifest.rb +26 -4
  25. data/lib/prompt_objects/environment.rb +1350 -18
  26. data/lib/prompt_objects/execution/blocking_executor.rb +88 -0
  27. data/lib/prompt_objects/execution/context.rb +137 -0
  28. data/lib/prompt_objects/execution/contracts.rb +59 -0
  29. data/lib/prompt_objects/execution/permit_pool.rb +49 -0
  30. data/lib/prompt_objects/execution/policy.rb +47 -0
  31. data/lib/prompt_objects/execution/resource_lock_manager.rb +63 -0
  32. data/lib/prompt_objects/execution/runner.rb +498 -0
  33. data/lib/prompt_objects/execution/scheduler.rb +325 -0
  34. data/lib/prompt_objects/llm/factory.rb +4 -4
  35. data/lib/prompt_objects/llm/gemini_adapter.rb +25 -8
  36. data/lib/prompt_objects/llm/openai_adapter.rb +9 -1
  37. data/lib/prompt_objects/llm/pricing.rb +9 -2
  38. data/lib/prompt_objects/llm/response.rb +9 -4
  39. data/lib/prompt_objects/loader.rb +28 -1
  40. data/lib/prompt_objects/mcp/server.rb +6 -3
  41. data/lib/prompt_objects/mcp/tools/get_conversation.rb +15 -11
  42. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +2 -2
  43. data/lib/prompt_objects/mcp/tools/respond_to_request.rb +3 -3
  44. data/lib/prompt_objects/mcp/tools/send_message.rb +42 -32
  45. data/lib/prompt_objects/operations/diagnostics.rb +115 -0
  46. data/lib/prompt_objects/operations/retention.rb +75 -0
  47. data/lib/prompt_objects/operations/run_trace_builder.rb +152 -0
  48. data/lib/prompt_objects/primitives/http_get.rb +2 -0
  49. data/lib/prompt_objects/primitives/list_files.rb +2 -0
  50. data/lib/prompt_objects/primitives/read_file.rb +2 -0
  51. data/lib/prompt_objects/primitives/think.rb +36 -0
  52. data/lib/prompt_objects/primitives/write_file.rb +9 -0
  53. data/lib/prompt_objects/prompt_object.rb +190 -432
  54. data/lib/prompt_objects/repositories/artifact_repository.rb +109 -0
  55. data/lib/prompt_objects/repositories/base_repository.rb +37 -0
  56. data/lib/prompt_objects/repositories/definition_repository.rb +36 -0
  57. data/lib/prompt_objects/repositories/env_data_repository.rb +103 -0
  58. data/lib/prompt_objects/repositories/event_repository.rb +105 -0
  59. data/lib/prompt_objects/repositories/message_repository.rb +73 -0
  60. data/lib/prompt_objects/repositories/run_repository.rb +254 -0
  61. data/lib/prompt_objects/repositories/thread_repository.rb +87 -0
  62. data/lib/prompt_objects/repositories/tool_execution_repository.rb +88 -0
  63. data/lib/prompt_objects/repositories/workspace_session_repository.rb +61 -0
  64. data/lib/prompt_objects/server/api/routes.rb +561 -52
  65. data/lib/prompt_objects/server/app.rb +134 -1
  66. data/lib/prompt_objects/server/file_watcher.rb +9 -0
  67. data/lib/prompt_objects/server/public/assets/index-BjKlODDD.js +90 -0
  68. data/lib/prompt_objects/server/public/assets/index-w3fpIb5c.css +1 -0
  69. data/lib/prompt_objects/server/public/index.html +2 -2
  70. data/lib/prompt_objects/server/websocket_handler.rb +202 -207
  71. data/lib/prompt_objects/server.rb +170 -1
  72. data/lib/prompt_objects/service.rb +129 -0
  73. data/lib/prompt_objects/session/store.rb +774 -15
  74. data/lib/prompt_objects/universal/add_capability.rb +1 -1
  75. data/lib/prompt_objects/universal/create_capability.rb +4 -2
  76. data/lib/prompt_objects/universal/delete_env_data.rb +78 -0
  77. data/lib/prompt_objects/universal/delete_primitive.rb +1 -1
  78. data/lib/prompt_objects/universal/dispatch_po.rb +54 -0
  79. data/lib/prompt_objects/universal/get_env_data.rb +58 -0
  80. data/lib/prompt_objects/universal/get_run_status.rb +30 -0
  81. data/lib/prompt_objects/universal/join_runs.rb +35 -0
  82. data/lib/prompt_objects/universal/list_env_data.rb +57 -0
  83. data/lib/prompt_objects/universal/modify_primitive.rb +1 -1
  84. data/lib/prompt_objects/universal/render_artifact.rb +91 -0
  85. data/lib/prompt_objects/universal/request_primitive.rb +1 -1
  86. data/lib/prompt_objects/universal/set_env_data.rb +101 -0
  87. data/lib/prompt_objects/version.rb +5 -0
  88. data/lib/prompt_objects.rb +36 -6
  89. data/objects/coordinator.md +8 -4
  90. data/templates/arc-agi-1/objects/data_manager.md +2 -2
  91. data/templates/arc-agi-1/objects/observer.md +6 -2
  92. data/templates/arc-agi-1/objects/solver.md +14 -4
  93. data/templates/arc-agi-1/objects/verifier.md +6 -2
  94. data/templates/arc-agi-3/manifest.yml +19 -0
  95. data/templates/arc-agi-3/objects/analyst.md +79 -0
  96. data/templates/arc-agi-3/objects/player.md +76 -0
  97. data/templates/arc-agi-3/objects/strategist.md +97 -0
  98. data/templates/arc-agi-3/primitives/action_log.rb +133 -0
  99. data/templates/arc-agi-3/primitives/arc3_api.rb +223 -0
  100. data/templates/arc-agi-3/primitives/arc3_scorecard.rb +131 -0
  101. data/templates/arc-agi-3/primitives/frame_diff.rb +143 -0
  102. data/templates/arc-agi-3/primitives/frame_info.rb +96 -0
  103. data/templates/arc-agi-3/primitives/render_frame.rb +140 -0
  104. data/templates/basic/objects/basic.md +11 -3
  105. data/templates/chiptune/extras/sonic_pi_boot.rb +220 -0
  106. data/templates/chiptune/manifest.yml +20 -0
  107. data/templates/chiptune/objects/conductor.md +184 -0
  108. data/templates/chiptune/objects/sonic_pi.md +65 -0
  109. data/templates/chiptune/objects/voice_bass.md +44 -0
  110. data/templates/chiptune/objects/voice_lead.md +44 -0
  111. data/templates/chiptune/objects/voice_pad.md +43 -0
  112. data/templates/chiptune/objects/voice_perc.md +45 -0
  113. data/templates/chiptune/primitives/play_note.rb +59 -0
  114. data/templates/chiptune/primitives/play_sequence.rb +55 -0
  115. data/templates/chiptune/primitives/query_sonic_pi.rb +141 -0
  116. data/templates/chiptune/primitives/set_synth.rb +57 -0
  117. data/templates/chiptune/primitives/set_tempo.rb +45 -0
  118. data/templates/chiptune/primitives/stop_voice.rb +46 -0
  119. data/templates/chiptune/primitives/support/osc_bridge.rb +33 -0
  120. data/templates/chiptune/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  121. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  122. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  123. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  124. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  125. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  126. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  127. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  128. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  129. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  130. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  131. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  132. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  133. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  134. data/templates/developer/objects/code_reviewer.md +1 -1
  135. data/templates/developer/objects/coordinator.md +16 -3
  136. data/templates/developer/objects/debugger.md +3 -3
  137. data/templates/empty/objects/assistant.md +10 -5
  138. data/templates/minimal/objects/assistant.md +10 -5
  139. data/templates/music/extras/sonic_pi_boot.rb +220 -0
  140. data/templates/music/manifest.yml +16 -0
  141. data/templates/music/objects/conductor.md +154 -0
  142. data/templates/music/objects/sonic_pi.md +65 -0
  143. data/templates/music/primitives/play_note.rb +55 -0
  144. data/templates/music/primitives/play_sequence.rb +51 -0
  145. data/templates/music/primitives/query_sonic_pi.rb +139 -0
  146. data/templates/music/primitives/set_synth.rb +53 -0
  147. data/templates/music/primitives/set_tempo.rb +43 -0
  148. data/templates/music/primitives/stop_voice.rb +44 -0
  149. data/templates/music/primitives/support/osc_bridge.rb +33 -0
  150. data/templates/music/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  151. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  152. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  153. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  154. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  155. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  156. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  157. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  158. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  159. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  160. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  161. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  162. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  163. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  164. data/templates/music-beginner/README.md +100 -0
  165. data/templates/music-beginner/extras/README.md +27 -0
  166. data/templates/music-beginner/extras/sonic_pi_boot.rb +297 -0
  167. data/templates/music-beginner/manifest.yml +25 -0
  168. data/templates/music-beginner/objects/conductor.md +222 -0
  169. data/templates/music-beginner/objects/engine_setup.md +54 -0
  170. data/templates/music-beginner/objects/voice_bass.md +43 -0
  171. data/templates/music-beginner/objects/voice_keys.md +48 -0
  172. data/templates/music-beginner/objects/voice_pad.md +46 -0
  173. data/templates/music-beginner/objects/voice_percussion.md +45 -0
  174. data/templates/music-beginner/objects/voice_texture.md +37 -0
  175. data/templates/music-beginner/primitives/play_pattern.rb +118 -0
  176. data/templates/music-beginner/primitives/query_music_engine.rb +94 -0
  177. data/templates/music-beginner/primitives/set_instrument.rb +47 -0
  178. data/templates/music-beginner/primitives/set_tempo.rb +43 -0
  179. data/templates/music-beginner/primitives/set_voice_level.rb +46 -0
  180. data/templates/music-beginner/primitives/stop_voice.rb +37 -0
  181. data/templates/music-beginner/primitives/support/osc_bridge.rb +25 -0
  182. data/templates/music-beginner/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  183. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  184. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  185. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  186. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  187. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  188. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  189. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  190. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  191. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  192. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  193. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  194. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  195. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  196. data/templates/music-pro/extras/AUDITION.md +100 -0
  197. data/templates/music-pro/extras/sonic_pi_boot.rb +546 -0
  198. data/templates/music-pro/manifest.yml +19 -0
  199. data/templates/music-pro/objects/conductor.md +126 -0
  200. data/templates/music-pro/objects/sonic_pi.md +35 -0
  201. data/templates/music-pro/objects/voice_harmony.md +48 -0
  202. data/templates/music-pro/objects/voice_rhythm.md +43 -0
  203. data/templates/music-pro/objects/voice_texture.md +42 -0
  204. data/templates/music-pro/primitives/define_patch.rb +46 -0
  205. data/templates/music-pro/primitives/query_music_engine.rb +69 -0
  206. data/templates/music-pro/primitives/set_live_pattern.rb +70 -0
  207. data/templates/music-pro/primitives/set_tempo.rb +31 -0
  208. data/templates/music-pro/primitives/stop_voice.rb +31 -0
  209. data/templates/music-pro/primitives/support/music_protocol.rb +136 -0
  210. data/templates/music-pro/primitives/support/osc_bridge.rb +33 -0
  211. data/templates/music-pro/primitives/trigger_event.rb +44 -0
  212. data/templates/music-pro/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  213. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  214. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  215. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  216. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  217. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  218. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  219. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  220. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  221. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  222. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  223. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  224. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  225. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  226. data/templates/writer/objects/coordinator.md +14 -3
  227. data/templates/writer/objects/editor.md +1 -1
  228. data/templates/writer/objects/researcher.md +2 -2
  229. data/tools/thread-explorer.html +28 -1
  230. metadata +175 -65
  231. data/CLAUDE.md +0 -176
  232. data/Gemfile +0 -10
  233. data/Gemfile.lock +0 -233
  234. data/IMPLEMENTATION_PLAN.md +0 -1073
  235. data/Rakefile +0 -27
  236. data/design-doc-v2.md +0 -1232
  237. data/frontend/.gitignore +0 -3
  238. data/frontend/index.html +0 -17
  239. data/frontend/package-lock.json +0 -4540
  240. data/frontend/package.json +0 -36
  241. data/frontend/postcss.config.js +0 -6
  242. data/frontend/src/App.tsx +0 -103
  243. data/frontend/src/canvas/CanvasView.tsx +0 -113
  244. data/frontend/src/canvas/ForceLayout.ts +0 -115
  245. data/frontend/src/canvas/SceneManager.ts +0 -587
  246. data/frontend/src/canvas/canvasStore.ts +0 -47
  247. data/frontend/src/canvas/constants.ts +0 -95
  248. data/frontend/src/canvas/controls/CameraControls.ts +0 -98
  249. data/frontend/src/canvas/edges/MessageArc.ts +0 -149
  250. data/frontend/src/canvas/inspector/InspectorPanel.tsx +0 -31
  251. data/frontend/src/canvas/inspector/POInspector.tsx +0 -262
  252. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +0 -67
  253. data/frontend/src/canvas/nodes/PONode.ts +0 -249
  254. data/frontend/src/canvas/nodes/ToolCallNode.ts +0 -116
  255. data/frontend/src/canvas/types.ts +0 -24
  256. data/frontend/src/components/ContextMenu.tsx +0 -68
  257. data/frontend/src/components/Inspector.tsx +0 -232
  258. data/frontend/src/components/MarkdownMessage.tsx +0 -155
  259. data/frontend/src/components/MethodList.tsx +0 -90
  260. data/frontend/src/components/ModelSelector.tsx +0 -115
  261. data/frontend/src/components/NotificationPanel.tsx +0 -130
  262. data/frontend/src/components/ObjectList.tsx +0 -78
  263. data/frontend/src/components/PaneSlot.tsx +0 -30
  264. data/frontend/src/components/SourcePane.tsx +0 -202
  265. data/frontend/src/components/SystemBar.tsx +0 -74
  266. data/frontend/src/components/Transcript.tsx +0 -76
  267. data/frontend/src/components/UsagePanel.tsx +0 -105
  268. data/frontend/src/components/Workspace.tsx +0 -260
  269. data/frontend/src/components/index.ts +0 -12
  270. data/frontend/src/hooks/useResize.ts +0 -55
  271. data/frontend/src/hooks/useWebSocket.ts +0 -517
  272. data/frontend/src/index.css +0 -103
  273. data/frontend/src/main.tsx +0 -10
  274. data/frontend/src/store/index.ts +0 -279
  275. data/frontend/src/types/index.ts +0 -165
  276. data/frontend/tailwind.config.js +0 -44
  277. data/frontend/tsconfig.json +0 -30
  278. data/frontend/vite.config.ts +0 -29
  279. data/lib/prompt_objects/server/public/assets/index-D1myxE0l.js +0 -4345
  280. data/lib/prompt_objects/server/public/assets/index-DdCcwC-Z.css +0 -1
  281. data/lib/prompt_objects/universal/add_primitive.rb +0 -118
  282. data/lib/prompt_objects/universal/ask_human.rb +0 -109
  283. data/lib/prompt_objects/universal/create_primitive.rb +0 -174
  284. data/lib/prompt_objects/universal/list_primitives.rb +0 -145
  285. data/lib/prompt_objects/universal/think.rb +0 -41
  286. data/prompt_objects.gemspec +0 -50
  287. data/templates/basic/.gitignore +0 -2
  288. data/templates/developer/.gitignore +0 -5
  289. data/templates/empty/.gitignore +0 -5
  290. data/templates/empty/objects/.gitkeep +0 -0
  291. data/templates/minimal/.gitignore +0 -5
  292. data/templates/writer/.gitignore +0 -5
@@ -1,17 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PromptObjects
4
- # A Prompt Object is a capability backed by an LLM.
5
- # It interprets messages semantically using its markdown "soul" as the system prompt.
4
+ # An immutable-at-execution-time LLM capability definition. Conversation
5
+ # state belongs to runtime threads, runs, messages, and events; this object
6
+ # only owns the editable definition loaded from markdown.
6
7
  class PromptObject < Capability
7
- attr_reader :config, :body, :history, :session_id, :path
8
- attr_accessor :on_history_updated # Callback for real-time updates during receive loop
9
-
10
- # @param config [Hash] Parsed frontmatter (name, description, capabilities)
11
- # @param body [String] Markdown body (the "soul" - becomes system prompt)
12
- # @param env [Environment] Reference to the environment
13
- # @param llm [LLM::OpenAIAdapter] LLM adapter for making calls
14
- # @param path [String, nil] Path to the source .md file (for persistence)
8
+ execution_policy parallel_safe: true, side_effect: :write, resource_keys: ->(_arguments, _context, _capability) { [] }
9
+
10
+ attr_reader :config, :body, :path
11
+
15
12
  def initialize(config:, body:, env:, llm:, path: nil)
16
13
  super()
17
14
  @config = config
@@ -19,17 +16,6 @@ module PromptObjects
19
16
  @env = env
20
17
  @llm = llm
21
18
  @path = path
22
- @history = []
23
- @session_id = nil
24
-
25
- # Load existing session if session store is available
26
- load_or_create_session if session_store
27
- end
28
-
29
- # Get the session store from the environment.
30
- # @return [Session::Store, nil]
31
- def session_store
32
- @env.session_store
33
19
  end
34
20
 
35
21
  def name
@@ -40,7 +26,24 @@ module PromptObjects
40
26
  @config["description"] || "A prompt object"
41
27
  end
42
28
 
43
- # Prompt objects accept natural language messages.
29
+ def autonomous?
30
+ !!@config["autonomous"]
31
+ end
32
+
33
+ def autonomous_config
34
+ return nil unless autonomous?
35
+
36
+ auto = @config["autonomous"]
37
+ auto = {} unless auto.is_a?(Hash)
38
+ {
39
+ "interval" => auto["interval"] || 30,
40
+ "initial_delay" => auto["initial_delay"] || auto["interval"] || 30,
41
+ "message" => auto["message"] || "Check your environment and act.",
42
+ "start_on_boot" => auto.fetch("start_on_boot", true),
43
+ "wait_for_human" => auto.fetch("wait_for_human", false)
44
+ }
45
+ end
46
+
44
47
  def parameters
45
48
  {
46
49
  type: "object",
@@ -54,271 +57,138 @@ module PromptObjects
54
57
  }
55
58
  end
56
59
 
57
- # Handle an incoming message by running the LLM conversation loop.
58
- # @param message [String, Hash] The incoming message
59
- # @param context [Hash] Execution context
60
- # @return [String] The response
61
- def receive(message, context:)
62
- # Normalize message to string
63
- content = normalize_message(message)
64
-
65
- # Track who sent this message - another PO or a human?
66
- sender = context.calling_po
67
- from = (sender && sender != name) ? sender : "human"
68
-
69
- user_msg = { role: :user, content: content, from: from }
70
- @history << user_msg
71
- persist_message(user_msg)
72
- @state = :working
73
-
74
- # Conversation loop - keep going until LLM responds without tool calls
75
- loop do
76
- response = @llm.chat(
77
- system: build_system_prompt,
78
- messages: @history,
79
- tools: available_tool_descriptors
80
- )
81
-
82
- if response.tool_calls?
83
- # Execute tools and continue the loop
84
- results = execute_tool_calls(response.tool_calls, context)
85
- assistant_msg = {
86
- role: :assistant,
87
- # Don't include content when there are tool calls - force LLM to
88
- # wait for tool results before generating a response. This prevents
89
- # the model from "hedging" by generating both a response AND a tool call.
90
- content: nil,
91
- tool_calls: response.tool_calls,
92
- usage: response.usage
93
- }
94
- @history << assistant_msg
95
- persist_message(assistant_msg)
96
-
97
- tool_msg = { role: :tool, results: results }
98
- @history << tool_msg
99
- persist_message(tool_msg)
100
-
101
- # Notify callback for real-time UI updates (tool calls as they happen)
102
- notify_history_updated
103
- else
104
- # No tool calls - we have our final response
105
- assistant_msg = { role: :assistant, content: response.content, usage: response.usage }
106
- @history << assistant_msg
107
- persist_message(assistant_msg)
108
- @state = :idle
109
- return response.content
110
- end
60
+ # The raw markdown source for definition editing. File watcher reloads
61
+ # replace instances, while direct edits update this memo in place.
62
+ def source
63
+ unless @source_loaded
64
+ @source_loaded = true
65
+ @source = (@path && File.exist?(@path)) ? File.read(@path, encoding: "UTF-8") : nil
111
66
  end
67
+ @source
112
68
  end
113
69
 
114
- # --- Session Management ---
115
-
116
- # List all sessions for this PO.
117
- # @return [Array<Hash>] Session data
118
- def list_sessions
119
- return [] unless session_store
120
-
121
- session_store.list_sessions(po_name: name)
122
- end
123
-
124
- # Switch to a different session.
125
- # @param session_id [String] Session ID to switch to
126
- # @return [Boolean] True if switch was successful
127
- def switch_session(session_id)
128
- return false unless session_store
129
-
130
- session = session_store.get_session(session_id)
131
- return false unless session && session[:po_name] == name
70
+ def update_source(raw)
71
+ return false unless @path
132
72
 
133
- @session_id = session_id
134
- reload_history_from_session
73
+ data = Loader.parse(raw, path: @path)
74
+ @env.suppress_next_reload(@path) if @env.respond_to?(:suppress_next_reload)
75
+ File.write(@path, raw, encoding: "UTF-8")
76
+ @config = data[:config]
77
+ @body = data[:body]
78
+ @source = raw
79
+ @source_loaded = true
135
80
  true
81
+ rescue => e
82
+ puts "Error updating source for PO #{name}: #{e.message}" if ENV["DEBUG"]
83
+ false
136
84
  end
137
85
 
138
- # Create a new session and switch to it.
139
- # @param name [String, nil] Optional session name
140
- # @return [String] New session ID
141
- def new_session(name: nil)
142
- return nil unless session_store
86
+ def relocate(new_path)
87
+ return false unless @path && File.exist?(@path)
88
+ return false if new_path == @path
89
+ return false if File.exist?(new_path)
143
90
 
144
- @session_id = session_store.create_session(po_name: self.name, name: name)
145
- @history = []
146
- @session_id
147
- end
148
-
149
- # Clear the current session's history.
150
- def clear_history
151
- @history = []
152
- session_store&.clear_messages(@session_id) if @session_id
91
+ File.rename(@path, new_path)
92
+ @path = new_path
93
+ true
94
+ rescue => e
95
+ puts "Error relocating PO #{name}: #{e.message}" if ENV["DEBUG"]
96
+ false
153
97
  end
154
98
 
155
- # --- File Persistence ---
156
-
157
- # Save the current config and body back to the source file.
158
- # This persists any runtime changes (like added capabilities) to disk.
159
- # @return [Boolean] True if saved successfully, false if no path or error
160
99
  def save
161
100
  return false unless @path
162
101
 
163
- # Build YAML frontmatter with proper formatting
164
- yaml_content = @config.to_yaml
165
-
166
- # Combine frontmatter and body
167
- content = "#{yaml_content}---\n\n#{@body}\n"
168
-
102
+ content = "#{save_frontmatter_yaml}---\n\n#{@body}\n"
103
+ @env.suppress_next_reload(@path) if @env.respond_to?(:suppress_next_reload)
169
104
  File.write(@path, content, encoding: "UTF-8")
105
+ @source = content
106
+ @source_loaded = true
170
107
  true
171
108
  rescue => e
172
109
  puts "Error saving PO #{name} to #{@path}: #{e.message}" if ENV["DEBUG"]
173
110
  false
174
111
  end
175
112
 
176
- # --- Thread/Delegation Support ---
177
-
178
- # Create a delegation thread for handling a call from another PO.
179
- # @param parent_po [String] Name of the PO that initiated the call
180
- # @param parent_session_id [String] Session ID in the parent PO
181
- # @param parent_message_id [Integer, nil] Message ID that triggered the delegation
182
- # @return [String, nil] New thread ID or nil if no session store
183
- def create_delegation_thread(parent_po:, parent_session_id:, parent_message_id: nil)
184
- return nil unless session_store
113
+ def save_frontmatter_yaml
114
+ frontmatter = @config.dup
115
+ capabilities = frontmatter.delete("capabilities") || []
185
116
 
186
- session_store.create_thread(
187
- po_name: name,
188
- parent_po: parent_po,
189
- parent_session_id: parent_session_id,
190
- parent_message_id: parent_message_id,
191
- thread_type: "delegation",
192
- source: "delegation"
193
- )
194
- end
195
-
196
- # Execute a message in a specific thread, then restore the original session.
197
- # @param message [String, Hash] The message to process
198
- # @param context [Context] Execution context
199
- # @param thread_id [String] Thread ID to execute in
200
- # @return [String] The response
201
- def receive_in_thread(message, context:, thread_id:)
202
- original_session = @session_id
203
- original_history = @history.dup
204
-
205
- # Switch to delegation thread
206
- @session_id = thread_id
207
- @history = []
208
- reload_history_from_session
209
-
210
- begin
211
- result = receive(message, context: context)
212
- result
213
- ensure
214
- # Restore original session
215
- @session_id = original_session
216
- @history = original_history
117
+ if capabilities.any?
118
+ registry = @env.respond_to?(:registry) ? @env.registry : nil
119
+ delegates, tools = capabilities.partition { |capability_name| registry&.get(capability_name).is_a?(PromptObject) }
120
+ frontmatter["delegates_to"] = delegates if delegates.any?
121
+ frontmatter["tools"] = tools if tools.any?
217
122
  end
218
- end
219
-
220
- # Create a new root thread and switch to it.
221
- # @param name [String, nil] Optional thread name
222
- # @return [String] New thread ID
223
- def new_thread(name: nil)
224
- return nil unless session_store
225
123
 
226
- @session_id = session_store.create_thread(
227
- po_name: self.name,
228
- name: name,
229
- thread_type: "root"
230
- )
231
- @history = []
232
- @session_id
124
+ frontmatter.to_yaml
233
125
  end
234
126
 
235
- # --- Serialization ---
236
- # Canonical methods for converting PO state to hashes for broadcasting,
237
- # REST API responses, and MCP tool output. All consumers should use these
238
- # to ensure consistent capability format across WebSocket, REST, and MCP.
239
-
240
- # Full state hash for WebSocket/broadcast consumers.
241
- # Matches the frontend's PromptObject TypeScript type.
242
- # @param registry [Registry, nil] Registry for resolving capability descriptions
243
- # @return [Hash]
127
+ # Definition state for WebSocket and REST consumers. Runtime activity is
128
+ # delivered separately from authoritative repository snapshots/events.
244
129
  def to_state_hash(registry: nil)
245
130
  {
246
- status: (@state || :idle).to_s,
131
+ status: "idle",
247
132
  description: description,
248
133
  capabilities: resolve_capabilities(registry: registry),
249
134
  universal_capabilities: resolve_universal_capabilities(registry: registry),
250
- current_session: serialize_current_session,
251
- sessions: list_sessions.map { |s| self.class.serialize_session(s) },
252
135
  prompt: body,
253
- config: config
136
+ config: config,
137
+ source: source
254
138
  }
255
139
  end
256
140
 
257
- # Summary hash for list endpoints (REST API, MCP list tools).
258
- # Lightweight: no prompt, no session messages, no universal capabilities.
259
- # @param registry [Registry, nil] Registry for resolving capability descriptions
260
- # @return [Hash]
261
141
  def to_summary_hash(registry: nil)
262
142
  {
263
143
  name: name,
264
144
  description: description,
265
- status: (@state || :idle).to_s,
266
- capabilities: resolve_capabilities(registry: registry),
267
- session_count: list_sessions.size
145
+ status: "idle",
146
+ capabilities: resolve_capabilities(registry: registry)
268
147
  }
269
148
  end
270
149
 
271
- # Detailed inspection hash for single-PO endpoints (REST GET, MCP inspect).
272
- # Everything in summary plus prompt, config, sessions, and universals.
273
- # @param registry [Registry, nil] Registry for resolving capability descriptions
274
- # @return [Hash]
275
150
  def to_inspect_hash(registry: nil)
276
151
  {
277
152
  name: name,
278
153
  description: description,
279
- status: (@state || :idle).to_s,
154
+ status: "idle",
280
155
  capabilities: resolve_capabilities(registry: registry),
281
156
  universal_capabilities: resolve_universal_capabilities(registry: registry),
282
157
  prompt: body,
283
158
  config: config,
284
- session_id: session_id,
285
- sessions: list_sessions.map { |s| self.class.serialize_session(s) },
286
- history_length: history.length
159
+ source: source
287
160
  }
288
161
  end
289
162
 
290
- # Serialize a message (in-memory or from DB) to a JSON-ready hash.
291
- # Handles both ToolCall objects and plain Hashes (from SQLite).
292
- # @param msg [Hash] The message to serialize
293
- # @return [Hash]
294
- def self.serialize_message(msg)
295
- case msg[:role]
163
+ # Transitional wire serializers for repository records. They do not read or
164
+ # mutate PromptObject state and can be removed with the older endpoint shape.
165
+ def self.serialize_message(message)
166
+ case message[:role]
296
167
  when :user
297
- from = msg[:from] || msg[:from_po]
298
- { role: "user", content: msg[:content], from: from }
168
+ { role: "user", content: message[:content], from: message[:from] || message[:from_po] }
299
169
  when :assistant
300
- hash = { role: "assistant", content: msg[:content] }
301
- if msg[:tool_calls]
302
- hash[:tool_calls] = msg[:tool_calls].map do |tc|
303
- if tc.respond_to?(:id)
304
- { id: tc.id, name: tc.name, arguments: tc.arguments }
170
+ hash = { role: "assistant", content: message[:content] }
171
+ if message[:tool_calls]
172
+ hash[:tool_calls] = message[:tool_calls].map do |tool_call|
173
+ if tool_call.respond_to?(:id)
174
+ { id: tool_call.id, name: tool_call.name, arguments: tool_call.arguments }
305
175
  else
306
- { id: tc[:id] || tc["id"], name: tc[:name] || tc["name"], arguments: tc[:arguments] || tc["arguments"] || {} }
176
+ {
177
+ id: tool_call[:id] || tool_call["id"],
178
+ name: tool_call[:name] || tool_call["name"],
179
+ arguments: tool_call[:arguments] || tool_call["arguments"] || {}
180
+ }
307
181
  end
308
182
  end
309
183
  end
310
184
  hash
311
185
  when :tool
312
- results = msg[:results] || msg[:tool_results]
313
- { role: "tool", results: results }
186
+ { role: "tool", results: message[:results] || message[:tool_results] }
314
187
  else
315
- { role: msg[:role].to_s, content: msg[:content] }
188
+ { role: message[:role].to_s, content: message[:content] }
316
189
  end
317
190
  end
318
191
 
319
- # Serialize a session record to a JSON-ready hash with thread fields.
320
- # @param session [Hash] Session record from session store
321
- # @return [Hash]
322
192
  def self.serialize_session(session)
323
193
  {
324
194
  id: session[:id],
@@ -331,241 +201,129 @@ module PromptObjects
331
201
  }
332
202
  end
333
203
 
204
+ # Capture the exact definition used by a run. Hot reload or definition
205
+ # editing cannot alter this deeply frozen snapshot after enqueue.
206
+ def execution_definition(provider: nil, model: nil, capability_policy_version: "1")
207
+ snapshot_config = deep_freeze(Marshal.load(Marshal.dump(@config)))
208
+ version_payload = JSON.generate(canonicalize({ "prompt" => @body, "config" => snapshot_config }))
209
+ Domain::PromptObjectDefinition.new(
210
+ po_name: name,
211
+ version: Digest::SHA256.hexdigest(version_payload),
212
+ prompt: @body.dup.freeze,
213
+ config: snapshot_config,
214
+ provider: provider,
215
+ model: model,
216
+ capability_policy_version: capability_policy_version
217
+ )
218
+ end
219
+
220
+ def system_prompt_for(definition)
221
+ build_system_prompt_from(
222
+ prompt: definition.prompt,
223
+ config: definition.config,
224
+ object_name: definition.po_name
225
+ )
226
+ end
227
+
228
+ def tool_descriptors_for(definition)
229
+ descriptors_for_config(definition.config)
230
+ end
231
+
334
232
  private
335
233
 
336
- # Build capability info objects for this PO's declared capabilities.
337
234
  def resolve_capabilities(registry: nil)
338
235
  declared = @config["capabilities"] || []
339
- return declared.map { |n| { name: n, description: n } } unless registry
340
-
341
- declared.map do |cap_name|
342
- cap = registry.get(cap_name)
343
- { name: cap_name, description: cap&.description || "Capability not found", parameters: cap&.parameters }
236
+ return declared.map { |capability_name| { name: capability_name, description: capability_name, kind: "tool" } } unless registry
237
+
238
+ declared.map do |capability_name|
239
+ capability = registry.get(capability_name)
240
+ kind = capability.is_a?(PromptObject) ? "delegate" : "tool"
241
+ {
242
+ name: capability_name,
243
+ description: capability&.description || "Capability not found",
244
+ parameters: capability&.parameters,
245
+ kind: kind
246
+ }
344
247
  end
345
248
  end
346
249
 
347
- # Build capability info objects for universal capabilities.
348
250
  def resolve_universal_capabilities(registry: nil)
349
251
  return [] unless registry
350
252
 
351
- UNIVERSAL_CAPABILITIES.map do |cap_name|
352
- cap = registry.get(cap_name)
353
- { name: cap_name, description: cap&.description || "Universal capability", parameters: cap&.parameters }
354
- end
355
- end
356
-
357
- # Serialize current session with messages for real-time state.
358
- def serialize_current_session
359
- return nil unless session_id
360
- { id: session_id, messages: history.map { |m| self.class.serialize_message(m) } }
361
- end
362
-
363
- def normalize_message(message)
364
- case message
365
- when String
366
- message
367
- when Hash
368
- message[:message] || message["message"] || message.to_s
369
- else
370
- message.to_s
253
+ UNIVERSAL_CAPABILITIES.map do |capability_name|
254
+ capability = registry.get(capability_name)
255
+ {
256
+ name: capability_name,
257
+ description: capability&.description || "Universal capability",
258
+ parameters: capability&.parameters
259
+ }
371
260
  end
372
261
  end
373
262
 
374
- def available_tool_descriptors
375
- # Get declared capabilities from config
376
- declared = @config["capabilities"] || []
377
-
378
- # Add universal capabilities (available to all POs)
379
- all_caps = declared + UNIVERSAL_CAPABILITIES
380
-
381
- all_caps.filter_map do |cap_name|
382
- cap = @env.registry&.get(cap_name)
383
- cap&.descriptor
263
+ def descriptors_for_config(config)
264
+ declared = config["capabilities"] || config[:capabilities] || []
265
+ (declared + UNIVERSAL_CAPABILITIES).filter_map do |capability_name|
266
+ @env.registry&.get(capability_name)&.descriptor
384
267
  end
385
268
  end
386
269
 
387
- def build_system_prompt
388
- # Build context about this PO's identity
389
- declared_caps = @config["capabilities"] || []
390
- all_caps = declared_caps + UNIVERSAL_CAPABILITIES
391
-
270
+ def build_system_prompt_from(prompt:, config:, object_name:)
271
+ declared_caps = config["capabilities"] || config[:capabilities] || []
392
272
  context_block = <<~CONTEXT
393
273
  ## System Context
394
274
 
395
- You are a prompt object named "#{name}".
396
- When using tools that target a PO (like add_capability), you can use "self" or "#{name}" to target yourself.
397
-
398
- Your declared capabilities: #{declared_caps.empty? ? '(none)' : declared_caps.join(', ')}
399
- Universal capabilities (always available): #{UNIVERSAL_CAPABILITIES.join(', ')}
275
+ You are a prompt object named "#{object_name}" running in a PromptObjects environment.
276
+
277
+ ### What is a Prompt Object?
278
+ You are an autonomous entity defined by a markdown file. You have an identity (your prompt),
279
+ capabilities (tools you can use), and you communicate by receiving messages and responding.
280
+ You exist alongside other prompt objects and primitive tools in a shared environment.
281
+
282
+ ### How you get called
283
+ You may receive messages from:
284
+ - **A human** interacting with you directly through the UI
285
+ - **Another prompt object** that has delegated a task to you as part of a larger workflow
286
+ - **Your own interface** — a person clicking a control in an artifact you rendered
287
+ (arrives from `artifact:<title>`)
288
+ - **A timer**, if you are configured as autonomous (arrives from `timer:#{object_name}`)
289
+
290
+ When called by another PO, you'll see a delegation context block in the message with details
291
+ about who called you and why. You can also check shared environment data (via `list_env_data`)
292
+ for context that other POs in the same workflow have stored.
293
+
294
+ ### Your capabilities
295
+ When using tools that target a PO (like add_capability), you can use "self" or "#{object_name}" to target yourself.
296
+ - Declared capabilities: #{declared_caps.empty? ? '(none)' : declared_caps.join(', ')}
297
+ - Universal capabilities (always available): #{UNIVERSAL_CAPABILITIES.join(', ')}
298
+
299
+ You can create new capabilities at runtime with `create_capability` — use `type: "primitive"` for a deterministic Ruby tool or `type: "prompt_object"` for a new specialist PO. Use `list_capabilities` to see everything available.
300
+
301
+ ### Your interface (interactive artifacts)
302
+ Besides replying in text, you can build a live UI with `render_artifact` — it renders on the
303
+ human's screen as your station. Artifacts are BIDIRECTIONAL: inside the artifact,
304
+ `PromptObjects.send("...")` sends a natural-language message back to you. Wire it to buttons,
305
+ forms, and controls so people can click instead of type — e.g.
306
+ `<button onClick={() => PromptObjects.send('approve invoice 3')}>Approve</button>`.
307
+ When an `artifact:<title>` message arrives, act on it, then update the UI by re-rendering with
308
+ the same title (replace mode). A re-render resets the artifact's local JS state, so render the
309
+ current state into the HTML each time. For anything with ongoing state — dashboards, controls,
310
+ forms, games — prefer offering an interface over walls of text.
400
311
  CONTEXT
401
312
 
402
- "#{@body}\n\n#{context_block}"
403
- end
404
-
405
- def execute_tool_calls(tool_calls, context)
406
- # Track the caller for nested calls
407
- previous_capability = context.current_capability
408
- previous_calling_po = context.calling_po
409
-
410
- tool_calls.map do |tc|
411
- capability = @env.registry&.get(tc.name)
412
-
413
- if capability
414
- # Log the outgoing message
415
- @env.bus.publish(from: name, to: tc.name, message: tc.arguments)
416
-
417
- # Set context for the tool call
418
- # calling_po tracks which PO is making the call (for "self" resolution)
419
- context.calling_po = name
420
- context.current_capability = tc.name
421
-
422
- result = if capability.is_a?(PromptObject)
423
- # PO-to-PO call: create isolated delegation thread
424
- execute_po_delegation(capability, tc, context)
425
- else
426
- # Primitive call: execute directly
427
- capability.receive(tc.arguments, context: context)
428
- end
429
-
430
- # Restore context
431
- context.current_capability = previous_capability
432
- context.calling_po = previous_calling_po
433
-
434
- # Log the response
435
- @env.bus.publish(from: tc.name, to: name, message: result)
436
-
437
- { tool_call_id: tc.id, name: tc.name, content: result }
438
- else
439
- { tool_call_id: tc.id, name: tc.name, content: "Unknown capability: #{tc.name}" }
440
- end
441
- end
442
- end
443
-
444
- # Execute a delegation to another PO in an isolated thread.
445
- # @param target_po [PromptObject] The PO to delegate to
446
- # @param tool_call [ToolCall] The tool call details
447
- # @param context [Context] Execution context
448
- # @return [String] The response
449
- def execute_po_delegation(target_po, tool_call, context)
450
- # Create a delegation thread in the target PO
451
- delegation_thread = target_po.create_delegation_thread(
452
- parent_po: name,
453
- parent_session_id: @session_id,
454
- parent_message_id: get_last_message_id
455
- )
456
-
457
- # Notify delegation start so WebSocket clients see the target PO activate
458
- @env.notify_delegation(:started, {
459
- target: target_po.name,
460
- caller: name,
461
- thread_id: delegation_thread,
462
- tool_call_id: tool_call.id
463
- })
464
-
465
- begin
466
- if delegation_thread
467
- # Execute in isolated thread
468
- target_po.receive_in_thread(tool_call.arguments, context: context, thread_id: delegation_thread)
469
- else
470
- # Fallback: execute in target's current session (no session store)
471
- target_po.receive(tool_call.arguments, context: context)
472
- end
473
- ensure
474
- # Notify delegation complete — target PO is done
475
- @env.notify_delegation(:completed, {
476
- target: target_po.name,
477
- caller: name,
478
- thread_id: delegation_thread,
479
- tool_call_id: tool_call.id
480
- })
481
- end
482
- end
483
-
484
- # Get the ID of the last message in the current session.
485
- # @return [Integer, nil]
486
- def get_last_message_id
487
- return nil unless session_store && @session_id
488
-
489
- messages = session_store.get_messages(@session_id)
490
- messages.last&.dig(:id)
491
- end
492
-
493
- # Notify the history updated callback if registered.
494
- # Used for real-time UI updates during the receive loop.
495
- def notify_history_updated
496
- @on_history_updated&.call(self, @session_id, @history)
497
- end
498
-
499
- # --- Session Persistence Helpers ---
500
-
501
- # Load existing session or create a new one.
502
- def load_or_create_session
503
- session = session_store.get_or_create_session(po_name: name)
504
- @session_id = session[:id]
505
- reload_history_from_session
313
+ "#{prompt}\n\n#{context_block}"
506
314
  end
507
315
 
508
- # Reload history from the current session.
509
- def reload_history_from_session
510
- return unless session_store && @session_id
511
-
512
- messages = session_store.get_messages(@session_id)
513
- @history = messages.map { |msg| convert_db_message_to_history(msg) }
316
+ def deep_freeze(value)
317
+ value.each { |key, nested| deep_freeze(key); deep_freeze(nested) } if value.is_a?(Hash)
318
+ value.each { |nested| deep_freeze(nested) } if value.is_a?(Array)
319
+ value.freeze
514
320
  end
515
321
 
516
- # Persist a message to the session store.
517
- def persist_message(msg)
518
- return unless session_store && @session_id
519
-
520
- case msg[:role]
521
- when :user
522
- session_store.add_message(
523
- session_id: @session_id,
524
- role: :user,
525
- content: msg[:content],
526
- from_po: msg[:from]
527
- )
528
- when :assistant
529
- # Serialize tool_calls if present
530
- tool_calls_data = msg[:tool_calls]&.map do |tc|
531
- { id: tc.id, name: tc.name, arguments: tc.arguments }
532
- end
533
-
534
- session_store.add_message(
535
- session_id: @session_id,
536
- role: :assistant,
537
- content: msg[:content],
538
- tool_calls: tool_calls_data,
539
- usage: msg[:usage]
540
- )
541
- when :tool
542
- session_store.add_message(
543
- session_id: @session_id,
544
- role: :tool,
545
- tool_results: msg[:results]
546
- )
547
- end
548
- end
322
+ def canonicalize(value)
323
+ return value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonicalize(value[key])] } if value.is_a?(Hash)
324
+ return value.map { |nested| canonicalize(nested) } if value.is_a?(Array)
549
325
 
550
- # Convert a database message row to history format.
551
- def convert_db_message_to_history(db_msg)
552
- case db_msg[:role]
553
- when :user
554
- { role: :user, content: db_msg[:content], from: db_msg[:from_po] || "human" }
555
- when :assistant
556
- msg = { role: :assistant, content: db_msg[:content] }
557
- if db_msg[:tool_calls]
558
- # Reconstruct tool call objects from Hashes
559
- msg[:tool_calls] = db_msg[:tool_calls].map do |tc|
560
- LLM::ToolCall.from_hash(tc)
561
- end
562
- end
563
- msg
564
- when :tool
565
- { role: :tool, results: db_msg[:tool_results] || [] }
566
- else
567
- { role: db_msg[:role], content: db_msg[:content] }
568
- end
326
+ value
569
327
  end
570
328
  end
571
329
  end