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,415 @@
1
+ # frozen_string_literal: true
2
+
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.
6
+ class PromptObject < Capability
7
+ attr_reader :config, :body, :history, :session_id, :path
8
+
9
+ # @param config [Hash] Parsed frontmatter (name, description, capabilities)
10
+ # @param body [String] Markdown body (the "soul" - becomes system prompt)
11
+ # @param env [Environment] Reference to the environment
12
+ # @param llm [LLM::OpenAIAdapter] LLM adapter for making calls
13
+ # @param path [String, nil] Path to the source .md file (for persistence)
14
+ def initialize(config:, body:, env:, llm:, path: nil)
15
+ super()
16
+ @config = config
17
+ @body = body
18
+ @env = env
19
+ @llm = llm
20
+ @path = path
21
+ @history = []
22
+ @session_id = nil
23
+
24
+ # Load existing session if session store is available
25
+ load_or_create_session if session_store
26
+ end
27
+
28
+ # Get the session store from the environment.
29
+ # @return [Session::Store, nil]
30
+ def session_store
31
+ @env.session_store
32
+ end
33
+
34
+ def name
35
+ @config["name"] || "unnamed"
36
+ end
37
+
38
+ def description
39
+ @config["description"] || "A prompt object"
40
+ end
41
+
42
+ # Prompt objects accept natural language messages.
43
+ def parameters
44
+ {
45
+ type: "object",
46
+ properties: {
47
+ message: {
48
+ type: "string",
49
+ description: "Natural language message to send"
50
+ }
51
+ },
52
+ required: ["message"]
53
+ }
54
+ end
55
+
56
+ # Handle an incoming message by running the LLM conversation loop.
57
+ # @param message [String, Hash] The incoming message
58
+ # @param context [Hash] Execution context
59
+ # @return [String] The response
60
+ def receive(message, context:)
61
+ # Normalize message to string
62
+ content = normalize_message(message)
63
+
64
+ # Track who sent this message - another PO or a human?
65
+ sender = context.current_capability
66
+ from = (sender && sender != name) ? sender : "human"
67
+
68
+ user_msg = { role: :user, content: content, from: from }
69
+ @history << user_msg
70
+ persist_message(user_msg)
71
+ @state = :working
72
+
73
+ # Conversation loop - keep going until LLM responds without tool calls
74
+ loop do
75
+ response = @llm.chat(
76
+ system: build_system_prompt,
77
+ messages: @history,
78
+ tools: available_tool_descriptors
79
+ )
80
+
81
+ if response.tool_calls?
82
+ # Execute tools and continue the loop
83
+ results = execute_tool_calls(response.tool_calls, context)
84
+ assistant_msg = {
85
+ role: :assistant,
86
+ # Don't include content when there are tool calls - force LLM to
87
+ # wait for tool results before generating a response. This prevents
88
+ # the model from "hedging" by generating both a response AND a tool call.
89
+ content: nil,
90
+ tool_calls: response.tool_calls
91
+ }
92
+ @history << assistant_msg
93
+ persist_message(assistant_msg)
94
+
95
+ tool_msg = { role: :tool, results: results }
96
+ @history << tool_msg
97
+ persist_message(tool_msg)
98
+ else
99
+ # No tool calls - we have our final response
100
+ assistant_msg = { role: :assistant, content: response.content }
101
+ @history << assistant_msg
102
+ persist_message(assistant_msg)
103
+ @state = :idle
104
+ return response.content
105
+ end
106
+ end
107
+ end
108
+
109
+ # --- Session Management ---
110
+
111
+ # List all sessions for this PO.
112
+ # @return [Array<Hash>] Session data
113
+ def list_sessions
114
+ return [] unless session_store
115
+
116
+ session_store.list_sessions(po_name: name)
117
+ end
118
+
119
+ # Switch to a different session.
120
+ # @param session_id [String] Session ID to switch to
121
+ # @return [Boolean] True if switch was successful
122
+ def switch_session(session_id)
123
+ return false unless session_store
124
+
125
+ session = session_store.get_session(session_id)
126
+ return false unless session && session[:po_name] == name
127
+
128
+ @session_id = session_id
129
+ reload_history_from_session
130
+ true
131
+ end
132
+
133
+ # Create a new session and switch to it.
134
+ # @param name [String, nil] Optional session name
135
+ # @return [String] New session ID
136
+ def new_session(name: nil)
137
+ return nil unless session_store
138
+
139
+ @session_id = session_store.create_session(po_name: self.name, name: name)
140
+ @history = []
141
+ @session_id
142
+ end
143
+
144
+ # Clear the current session's history.
145
+ def clear_history
146
+ @history = []
147
+ session_store&.clear_messages(@session_id) if @session_id
148
+ end
149
+
150
+ # --- File Persistence ---
151
+
152
+ # Save the current config and body back to the source file.
153
+ # This persists any runtime changes (like added capabilities) to disk.
154
+ # @return [Boolean] True if saved successfully, false if no path or error
155
+ def save
156
+ return false unless @path
157
+
158
+ # Build YAML frontmatter with proper formatting
159
+ yaml_content = @config.to_yaml
160
+
161
+ # Combine frontmatter and body
162
+ content = "#{yaml_content}---\n\n#{@body}\n"
163
+
164
+ File.write(@path, content, encoding: "UTF-8")
165
+ true
166
+ rescue => e
167
+ puts "Error saving PO #{name} to #{@path}: #{e.message}" if ENV["DEBUG"]
168
+ false
169
+ end
170
+
171
+ # --- Thread/Delegation Support ---
172
+
173
+ # Create a delegation thread for handling a call from another PO.
174
+ # @param parent_po [String] Name of the PO that initiated the call
175
+ # @param parent_session_id [String] Session ID in the parent PO
176
+ # @param parent_message_id [Integer, nil] Message ID that triggered the delegation
177
+ # @return [String, nil] New thread ID or nil if no session store
178
+ def create_delegation_thread(parent_po:, parent_session_id:, parent_message_id: nil)
179
+ return nil unless session_store
180
+
181
+ session_store.create_thread(
182
+ po_name: name,
183
+ parent_po: parent_po,
184
+ parent_session_id: parent_session_id,
185
+ parent_message_id: parent_message_id,
186
+ thread_type: "delegation",
187
+ source: "delegation"
188
+ )
189
+ end
190
+
191
+ # Execute a message in a specific thread, then restore the original session.
192
+ # @param message [String, Hash] The message to process
193
+ # @param context [Context] Execution context
194
+ # @param thread_id [String] Thread ID to execute in
195
+ # @return [String] The response
196
+ def receive_in_thread(message, context:, thread_id:)
197
+ original_session = @session_id
198
+ original_history = @history.dup
199
+
200
+ # Switch to delegation thread
201
+ @session_id = thread_id
202
+ @history = []
203
+ reload_history_from_session
204
+
205
+ begin
206
+ result = receive(message, context: context)
207
+ result
208
+ ensure
209
+ # Restore original session
210
+ @session_id = original_session
211
+ @history = original_history
212
+ end
213
+ end
214
+
215
+ # Create a new root thread and switch to it.
216
+ # @param name [String, nil] Optional thread name
217
+ # @return [String] New thread ID
218
+ def new_thread(name: nil)
219
+ return nil unless session_store
220
+
221
+ @session_id = session_store.create_thread(
222
+ po_name: self.name,
223
+ name: name,
224
+ thread_type: "root"
225
+ )
226
+ @history = []
227
+ @session_id
228
+ end
229
+
230
+ private
231
+
232
+ def normalize_message(message)
233
+ case message
234
+ when String
235
+ message
236
+ when Hash
237
+ message[:message] || message["message"] || message.to_s
238
+ else
239
+ message.to_s
240
+ end
241
+ end
242
+
243
+ def available_tool_descriptors
244
+ # Get declared capabilities from config
245
+ declared = @config["capabilities"] || []
246
+
247
+ # Add universal capabilities (available to all POs)
248
+ all_caps = declared + UNIVERSAL_CAPABILITIES
249
+
250
+ all_caps.filter_map do |cap_name|
251
+ cap = @env.registry&.get(cap_name)
252
+ cap&.descriptor
253
+ end
254
+ end
255
+
256
+ def build_system_prompt
257
+ # Build context about this PO's identity
258
+ declared_caps = @config["capabilities"] || []
259
+ all_caps = declared_caps + UNIVERSAL_CAPABILITIES
260
+
261
+ context_block = <<~CONTEXT
262
+ ## System Context
263
+
264
+ You are a prompt object named "#{name}".
265
+ When using tools that target a PO (like add_capability), you can use "self" or "#{name}" to target yourself.
266
+
267
+ Your declared capabilities: #{declared_caps.empty? ? '(none)' : declared_caps.join(', ')}
268
+ Universal capabilities (always available): #{UNIVERSAL_CAPABILITIES.join(', ')}
269
+ CONTEXT
270
+
271
+ "#{@body}\n\n#{context_block}"
272
+ end
273
+
274
+ def execute_tool_calls(tool_calls, context)
275
+ # Track the caller for nested calls
276
+ previous_capability = context.current_capability
277
+ previous_calling_po = context.calling_po
278
+
279
+ tool_calls.map do |tc|
280
+ capability = @env.registry&.get(tc.name)
281
+
282
+ if capability
283
+ # Log the outgoing message
284
+ @env.bus.publish(from: name, to: tc.name, message: tc.arguments)
285
+
286
+ # Set context for the tool call
287
+ # calling_po tracks which PO is making the call (for "self" resolution)
288
+ context.calling_po = name
289
+ context.current_capability = tc.name
290
+
291
+ result = if capability.is_a?(PromptObject)
292
+ # PO-to-PO call: create isolated delegation thread
293
+ execute_po_delegation(capability, tc, context)
294
+ else
295
+ # Primitive call: execute directly
296
+ capability.receive(tc.arguments, context: context)
297
+ end
298
+
299
+ # Restore context
300
+ context.current_capability = previous_capability
301
+ context.calling_po = previous_calling_po
302
+
303
+ # Log the response
304
+ @env.bus.publish(from: tc.name, to: name, message: result)
305
+
306
+ { tool_call_id: tc.id, name: tc.name, content: result }
307
+ else
308
+ { tool_call_id: tc.id, name: tc.name, content: "Unknown capability: #{tc.name}" }
309
+ end
310
+ end
311
+ end
312
+
313
+ # Execute a delegation to another PO in an isolated thread.
314
+ # @param target_po [PromptObject] The PO to delegate to
315
+ # @param tool_call [ToolCall] The tool call details
316
+ # @param context [Context] Execution context
317
+ # @return [String] The response
318
+ def execute_po_delegation(target_po, tool_call, context)
319
+ # Create a delegation thread in the target PO
320
+ delegation_thread = target_po.create_delegation_thread(
321
+ parent_po: name,
322
+ parent_session_id: @session_id,
323
+ parent_message_id: get_last_message_id
324
+ )
325
+
326
+ if delegation_thread
327
+ # Execute in isolated thread
328
+ target_po.receive_in_thread(tool_call.arguments, context: context, thread_id: delegation_thread)
329
+ else
330
+ # Fallback: execute in target's current session (no session store)
331
+ target_po.receive(tool_call.arguments, context: context)
332
+ end
333
+ end
334
+
335
+ # Get the ID of the last message in the current session.
336
+ # @return [Integer, nil]
337
+ def get_last_message_id
338
+ return nil unless session_store && @session_id
339
+
340
+ messages = session_store.get_messages(@session_id)
341
+ messages.last&.dig(:id)
342
+ end
343
+
344
+ # --- Session Persistence Helpers ---
345
+
346
+ # Load existing session or create a new one.
347
+ def load_or_create_session
348
+ session = session_store.get_or_create_session(po_name: name)
349
+ @session_id = session[:id]
350
+ reload_history_from_session
351
+ end
352
+
353
+ # Reload history from the current session.
354
+ def reload_history_from_session
355
+ return unless session_store && @session_id
356
+
357
+ messages = session_store.get_messages(@session_id)
358
+ @history = messages.map { |msg| convert_db_message_to_history(msg) }
359
+ end
360
+
361
+ # Persist a message to the session store.
362
+ def persist_message(msg)
363
+ return unless session_store && @session_id
364
+
365
+ case msg[:role]
366
+ when :user
367
+ session_store.add_message(
368
+ session_id: @session_id,
369
+ role: :user,
370
+ content: msg[:content],
371
+ from_po: msg[:from]
372
+ )
373
+ when :assistant
374
+ # Serialize tool_calls if present
375
+ tool_calls_data = msg[:tool_calls]&.map do |tc|
376
+ { id: tc.id, name: tc.name, arguments: tc.arguments }
377
+ end
378
+
379
+ session_store.add_message(
380
+ session_id: @session_id,
381
+ role: :assistant,
382
+ content: msg[:content],
383
+ tool_calls: tool_calls_data
384
+ )
385
+ when :tool
386
+ session_store.add_message(
387
+ session_id: @session_id,
388
+ role: :tool,
389
+ tool_results: msg[:results]
390
+ )
391
+ end
392
+ end
393
+
394
+ # Convert a database message row to history format.
395
+ def convert_db_message_to_history(db_msg)
396
+ case db_msg[:role]
397
+ when :user
398
+ { role: :user, content: db_msg[:content], from: db_msg[:from_po] || "human" }
399
+ when :assistant
400
+ msg = { role: :assistant, content: db_msg[:content] }
401
+ if db_msg[:tool_calls]
402
+ # Reconstruct tool call objects from Hashes
403
+ msg[:tool_calls] = db_msg[:tool_calls].map do |tc|
404
+ LLM::ToolCall.from_hash(tc)
405
+ end
406
+ end
407
+ msg
408
+ when :tool
409
+ { role: :tool, results: db_msg[:tool_results] || [] }
410
+ else
411
+ { role: db_msg[:role], content: db_msg[:content] }
412
+ end
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ # Registry for all capabilities (both primitives and prompt objects).
5
+ # Provides lookup by name and generates tool descriptors for LLM calls.
6
+ class Registry
7
+ def initialize
8
+ @capabilities = {}
9
+ end
10
+
11
+ # Register a capability.
12
+ # @param capability [Capability] The capability to register
13
+ # @return [Capability] The registered capability
14
+ def register(capability)
15
+ @capabilities[capability.name] = capability
16
+ capability
17
+ end
18
+
19
+ # Unregister a capability by name.
20
+ # @param name [String] The capability name
21
+ # @return [Capability, nil] The removed capability or nil
22
+ def unregister(name)
23
+ @capabilities.delete(name.to_s)
24
+ end
25
+
26
+ # Get a capability by name.
27
+ # @param name [String] The capability name
28
+ # @return [Capability, nil] The capability or nil if not found
29
+ def get(name)
30
+ @capabilities[name.to_s]
31
+ end
32
+
33
+ # Check if a capability exists.
34
+ # @param name [String] The capability name
35
+ # @return [Boolean]
36
+ def exists?(name)
37
+ @capabilities.key?(name.to_s)
38
+ end
39
+
40
+ # Get all registered capabilities.
41
+ # @return [Array<Capability>]
42
+ def all
43
+ @capabilities.values
44
+ end
45
+
46
+ # Get all capability names.
47
+ # @return [Array<String>]
48
+ def names
49
+ @capabilities.keys
50
+ end
51
+
52
+ # Get only prompt objects.
53
+ # @return [Array<PromptObject>]
54
+ def prompt_objects
55
+ @capabilities.values.select { |c| c.is_a?(PromptObject) }
56
+ end
57
+
58
+ # Get only primitives.
59
+ # @return [Array<Primitive>]
60
+ def primitives
61
+ @capabilities.values.select { |c| c.is_a?(Primitive) }
62
+ end
63
+
64
+ # Check if a capability is a prompt object.
65
+ # @param name [String] The capability name
66
+ # @return [Boolean]
67
+ def prompt_object?(name)
68
+ cap = get(name)
69
+ cap.is_a?(PromptObject)
70
+ end
71
+
72
+ # Get tool descriptors for a list of capability names.
73
+ # @param names [Array<String>] Capability names
74
+ # @return [Array<Hash>] Tool descriptors for LLM
75
+ def descriptors_for(names)
76
+ names.filter_map do |name|
77
+ cap = get(name)
78
+ cap&.descriptor
79
+ end
80
+ end
81
+
82
+ # Get tool descriptors for all capabilities.
83
+ # @return [Array<Hash>]
84
+ def all_descriptors
85
+ @capabilities.values.map(&:descriptor)
86
+ end
87
+ end
88
+ end