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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "time"
5
+
6
+ module PromptObjects
7
+ module Env
8
+ # Handles environment manifest files (manifest.yml).
9
+ # Contains metadata about the environment: name, description, timestamps,
10
+ # UI customization, and stats.
11
+ class Manifest
12
+ FILENAME = "manifest.yml"
13
+ FORMAT_VERSION = 1 # Increment when manifest schema changes
14
+
15
+ attr_accessor :name, :description, :created_at, :updated_at, :last_opened,
16
+ :icon, :color, :tags, :default_po, :stats, :version,
17
+ :archived_at, :imported_from, :imported_at
18
+
19
+ def initialize(
20
+ name:,
21
+ description: nil,
22
+ created_at: nil,
23
+ updated_at: nil,
24
+ last_opened: nil,
25
+ icon: nil,
26
+ color: nil,
27
+ tags: nil,
28
+ default_po: nil,
29
+ stats: nil,
30
+ version: nil,
31
+ archived_at: nil,
32
+ imported_from: nil,
33
+ imported_at: nil
34
+ )
35
+ @name = name
36
+ @description = description
37
+ @created_at = created_at || Time.now
38
+ @updated_at = updated_at || Time.now
39
+ @last_opened = last_opened
40
+ @icon = icon || "📦"
41
+ @color = color || "#4A90D9"
42
+ @tags = tags || []
43
+ @default_po = default_po
44
+ @stats = stats || { "total_messages" => 0, "total_sessions" => 0, "po_count" => 0 }
45
+ @version = version || FORMAT_VERSION
46
+ @archived_at = archived_at
47
+ @imported_from = imported_from
48
+ @imported_at = imported_at
49
+ end
50
+
51
+ # Load manifest from a file path.
52
+ # @param path [String] Path to manifest.yml
53
+ # @return [Manifest]
54
+ def self.load(path)
55
+ raise Error, "Manifest not found: #{path}" unless File.exist?(path)
56
+
57
+ data = YAML.safe_load(File.read(path), permitted_classes: [Time, Symbol])
58
+ from_hash(data)
59
+ end
60
+
61
+ # Load manifest from an environment directory.
62
+ # @param env_dir [String] Path to environment directory
63
+ # @return [Manifest]
64
+ def self.load_from_dir(env_dir)
65
+ load(File.join(env_dir, FILENAME))
66
+ end
67
+
68
+ # Create manifest from a hash (parsed YAML).
69
+ # @param data [Hash]
70
+ # @return [Manifest]
71
+ def self.from_hash(data)
72
+ new(
73
+ name: data["name"],
74
+ description: data["description"],
75
+ created_at: parse_time(data["created_at"]),
76
+ updated_at: parse_time(data["updated_at"]),
77
+ last_opened: parse_time(data["last_opened"]),
78
+ icon: data["icon"],
79
+ color: data["color"],
80
+ tags: data["tags"],
81
+ default_po: data["default_po"],
82
+ stats: data["stats"],
83
+ version: data["version"],
84
+ archived_at: parse_time(data["archived_at"]),
85
+ imported_from: data["imported_from"],
86
+ imported_at: parse_time(data["imported_at"])
87
+ )
88
+ end
89
+
90
+ # Parse time from various formats.
91
+ # @param value [String, Time, nil]
92
+ # @return [Time, nil]
93
+ def self.parse_time(value)
94
+ return nil if value.nil?
95
+ return value if value.is_a?(Time)
96
+
97
+ Time.parse(value)
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+
102
+ # Convert to hash for YAML serialization.
103
+ # @return [Hash]
104
+ def to_hash
105
+ {
106
+ "version" => @version,
107
+ "name" => @name,
108
+ "description" => @description,
109
+ "created_at" => @created_at&.iso8601,
110
+ "updated_at" => @updated_at&.iso8601,
111
+ "last_opened" => @last_opened&.iso8601,
112
+ "archived_at" => @archived_at&.iso8601,
113
+ "imported_from" => @imported_from,
114
+ "imported_at" => @imported_at&.iso8601,
115
+ "icon" => @icon,
116
+ "color" => @color,
117
+ "tags" => @tags.any? ? @tags : nil,
118
+ "default_po" => @default_po,
119
+ "stats" => @stats
120
+ }.compact
121
+ end
122
+
123
+ # Save manifest to a file.
124
+ # @param path [String] Path to save manifest.yml
125
+ def save(path)
126
+ @updated_at = Time.now
127
+ File.write(path, to_hash.to_yaml)
128
+ end
129
+
130
+ # Save manifest to an environment directory.
131
+ # @param env_dir [String] Path to environment directory
132
+ def save_to_dir(env_dir)
133
+ save(File.join(env_dir, FILENAME))
134
+ end
135
+
136
+ # Mark environment as opened and update timestamp.
137
+ def touch_opened!
138
+ @last_opened = Time.now
139
+ @updated_at = Time.now
140
+ end
141
+
142
+ # Update stats from environment data.
143
+ # @param po_count [Integer] Number of prompt objects
144
+ # @param session_count [Integer] Number of sessions
145
+ # @param message_count [Integer] Total messages
146
+ def update_stats(po_count: nil, session_count: nil, message_count: nil)
147
+ @stats["po_count"] = po_count if po_count
148
+ @stats["total_sessions"] = session_count if session_count
149
+ @stats["total_messages"] = message_count if message_count
150
+ @updated_at = Time.now
151
+ end
152
+
153
+ # Increment message count.
154
+ def increment_messages!
155
+ @stats["total_messages"] = (@stats["total_messages"] || 0) + 1
156
+ end
157
+
158
+ # Mark as archived with timestamp.
159
+ def mark_archived!
160
+ @archived_at = Time.now
161
+ @updated_at = Time.now
162
+ end
163
+
164
+ # Check if this environment was imported.
165
+ # @return [Boolean]
166
+ def imported?
167
+ !@imported_from.nil?
168
+ end
169
+
170
+ # Check if this environment was archived.
171
+ # @return [Boolean]
172
+ def archived?
173
+ !@archived_at.nil?
174
+ end
175
+
176
+ # Display string.
177
+ # @return [String]
178
+ def to_s
179
+ "#{@icon} #{@name}"
180
+ end
181
+
182
+ # Detailed info string.
183
+ # @return [String]
184
+ def info
185
+ lines = []
186
+ lines << "#{@icon} #{@name}"
187
+ lines << " #{@description}" if @description
188
+ lines << ""
189
+ lines << " Created: #{@created_at&.strftime('%Y-%m-%d %H:%M')}"
190
+ lines << " Last opened: #{@last_opened&.strftime('%Y-%m-%d %H:%M')}" if @last_opened
191
+ lines << " Updated: #{@updated_at&.strftime('%Y-%m-%d %H:%M')}" if @updated_at
192
+
193
+ if imported?
194
+ lines << ""
195
+ lines << " Imported from: #{@imported_from}"
196
+ lines << " Imported at: #{@imported_at&.strftime('%Y-%m-%d %H:%M')}"
197
+ end
198
+
199
+ if archived?
200
+ lines << ""
201
+ lines << " Archived at: #{@archived_at&.strftime('%Y-%m-%d %H:%M')}"
202
+ end
203
+
204
+ lines << ""
205
+ lines << " Stats:"
206
+ lines << " Objects: #{@stats['po_count'] || 0}"
207
+ lines << " Sessions: #{@stats['total_sessions'] || 0}"
208
+ lines << " Messages: #{@stats['total_messages'] || 0}"
209
+
210
+ lines << ""
211
+ lines << " Tags: #{@tags.join(', ')}" if @tags&.any?
212
+ lines << " Format version: #{@version}"
213
+
214
+ lines.join("\n")
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ # Context object passed to capabilities during execution.
5
+ # Provides access to environment, message bus, and tracks execution.
6
+ class Context
7
+ attr_reader :env, :bus, :human_queue
8
+ attr_accessor :current_capability, :calling_po, :tui_mode
9
+
10
+ def initialize(env:, bus:, human_queue: nil)
11
+ @env = env
12
+ @bus = bus
13
+ @human_queue = human_queue
14
+ @current_capability = nil
15
+ @calling_po = nil # The PO that initiated the tool call (for resolving "self")
16
+ @tui_mode = false
17
+ end
18
+
19
+ # Log a message to the bus.
20
+ # @param to [String] Destination capability
21
+ # @param message [String, Hash] The message
22
+ def log_message(to:, message:)
23
+ @bus.publish(from: @current_capability || "human", to: to, message: message)
24
+ end
25
+
26
+ # Log a response to the bus.
27
+ # @param from [String] Source capability
28
+ # @param message [String, Hash] The response
29
+ def log_response(from:, message:)
30
+ @bus.publish(from: from, to: @current_capability || "human", message: message)
31
+ end
32
+ end
33
+
34
+ # The runtime environment that holds all capabilities and coordinates execution.
35
+ # Can be initialized either from:
36
+ # - A user environment directory (with manifest.yml, git integration)
37
+ # - A simple objects directory (legacy/development mode)
38
+ class Runtime
39
+ attr_reader :llm, :registry, :objects_dir, :bus, :primitives_dir, :human_queue,
40
+ :manifest, :env_path, :auto_commit, :session_store,
41
+ :current_provider, :current_model
42
+ attr_accessor :on_po_registered # Callback for when a PO is registered
43
+
44
+ # Initialize from an environment path (with manifest) or objects directory.
45
+ # @param env_path [String, nil] Path to environment directory (preferred)
46
+ # @param objects_dir [String, nil] Legacy: path to objects directory
47
+ # @param primitives_dir [String, nil] Path to primitives directory
48
+ # @param llm [Object, nil] LLM adapter (deprecated, use provider/model instead)
49
+ # @param provider [String, nil] LLM provider (openai, anthropic, gemini)
50
+ # @param model [String, nil] Model name
51
+ # @param auto_commit [Boolean] Auto-commit changes to git (default: true for env_path)
52
+ def initialize(env_path: nil, objects_dir: nil, primitives_dir: nil, llm: nil, provider: nil, model: nil, auto_commit: nil)
53
+ if env_path
54
+ # Environment-based initialization
55
+ @env_path = env_path
56
+ @objects_dir = File.join(env_path, "objects")
57
+ @primitives_dir = primitives_dir || File.join(env_path, "primitives")
58
+ @manifest = Env::Manifest.load_from_dir(env_path)
59
+ @auto_commit = auto_commit.nil? ? true : auto_commit
60
+ @manifest.touch_opened!
61
+ @manifest.save_to_dir(env_path)
62
+
63
+ # Initialize session store
64
+ db_path = File.join(env_path, "sessions.db")
65
+ @session_store = Session::Store.new(db_path)
66
+ else
67
+ # Legacy objects_dir initialization
68
+ @env_path = nil
69
+ @objects_dir = objects_dir || "objects"
70
+ @primitives_dir = primitives_dir || File.join(File.dirname(@objects_dir), "primitives")
71
+ @manifest = nil
72
+ @auto_commit = auto_commit || false
73
+ @session_store = nil # No persistent sessions in legacy mode
74
+ end
75
+
76
+ # Initialize LLM - prefer explicit llm, then use factory with provider/model
77
+ if llm
78
+ @llm = llm
79
+ @current_provider = nil
80
+ @current_model = nil
81
+ else
82
+ @current_provider = provider || LLM::Factory::DEFAULT_PROVIDER
83
+ @current_model = model || LLM::Factory.default_model(@current_provider)
84
+ @llm = LLM::Factory.create(provider: @current_provider, model: @current_model)
85
+ end
86
+
87
+ @registry = Registry.new
88
+ @bus = MessageBus.new
89
+ @human_queue = HumanQueue.new
90
+
91
+ register_primitives
92
+ register_universal_capabilities
93
+ end
94
+
95
+ # Create runtime from a user environment by name.
96
+ # @param name [String] Environment name
97
+ # @param manager [Env::Manager, nil] Manager instance
98
+ # @return [Runtime]
99
+ def self.from_environment(name, manager: nil)
100
+ manager ||= Env::Manager.new
101
+ raise Error, "Environment '#{name}' not found" unless manager.environment_exists?(name)
102
+
103
+ new(env_path: manager.environment_path(name))
104
+ end
105
+
106
+ # Name of the environment (from manifest or directory name).
107
+ # @return [String]
108
+ def name
109
+ @manifest&.name || File.basename(@objects_dir)
110
+ end
111
+
112
+ # Check if this is an environment-based runtime (vs legacy objects_dir).
113
+ # @return [Boolean]
114
+ def environment?
115
+ !@env_path.nil?
116
+ end
117
+
118
+ # Save a commit with all current changes.
119
+ # @param message [String]
120
+ # @return [Boolean] True if committed
121
+ def save(message = "Save changes")
122
+ return false unless environment? && @auto_commit
123
+
124
+ Env::Git.auto_commit(@env_path, message)
125
+ end
126
+
127
+ # Check if there are unsaved changes.
128
+ # @return [Boolean]
129
+ def dirty?
130
+ return false unless environment?
131
+
132
+ Env::Git.dirty?(@env_path)
133
+ end
134
+
135
+ # Switch to a different LLM provider/model.
136
+ # @param provider [String] Provider name (openai, anthropic, gemini)
137
+ # @param model [String, nil] Model name (defaults to provider's default)
138
+ # @return [Hash] New provider/model info
139
+ def switch_llm(provider:, model: nil)
140
+ @current_provider = provider
141
+ @current_model = model || LLM::Factory.default_model(provider)
142
+ @llm = LLM::Factory.create(provider: @current_provider, model: @current_model)
143
+
144
+ # Update all loaded POs to use the new LLM
145
+ @registry.prompt_objects.each do |po|
146
+ po.instance_variable_set(:@llm, @llm)
147
+ end
148
+
149
+ { provider: @current_provider, model: @current_model }
150
+ end
151
+
152
+ # Get current LLM configuration.
153
+ # @return [Hash]
154
+ def llm_config
155
+ {
156
+ provider: @current_provider,
157
+ model: @current_model,
158
+ providers: LLM::Factory.providers,
159
+ available: LLM::Factory.available_providers
160
+ }
161
+ end
162
+
163
+ # Update manifest stats from current state.
164
+ def update_manifest_stats!
165
+ return unless @manifest
166
+
167
+ stats = { po_count: @registry.prompt_objects.size }
168
+
169
+ # Add session stats if available
170
+ if @session_store
171
+ stats[:total_sessions] = @session_store.total_sessions
172
+ stats[:total_messages] = @session_store.total_messages
173
+ end
174
+
175
+ @manifest.update_stats(**stats)
176
+ @manifest.save_to_dir(@env_path)
177
+ end
178
+
179
+ # Create a context for capability execution.
180
+ # @param tui_mode [Boolean] Whether running in TUI mode
181
+ # @return [Context]
182
+ def context(tui_mode: false)
183
+ ctx = Context.new(env: self, bus: @bus, human_queue: @human_queue)
184
+ ctx.tui_mode = tui_mode
185
+ ctx
186
+ end
187
+
188
+ # Load a prompt object from a file path.
189
+ # @param path [String] Path to the .md file
190
+ # @return [PromptObject]
191
+ def load_prompt_object(path)
192
+ data = Loader.load(path)
193
+
194
+ po = PromptObject.new(
195
+ config: data[:config],
196
+ body: data[:body],
197
+ env: self,
198
+ llm: @llm,
199
+ path: data[:path]
200
+ )
201
+
202
+ @registry.register(po)
203
+
204
+ # Notify callback if registered (for live updates in web UI)
205
+ @on_po_registered&.call(po)
206
+
207
+ po
208
+ end
209
+
210
+ # Load a prompt object by name from the objects directory.
211
+ # @param name [String] Name of the prompt object (without .md extension)
212
+ # @return [PromptObject]
213
+ def load_by_name(name)
214
+ path = File.join(@objects_dir, "#{name}.md")
215
+ load_prompt_object(path)
216
+ end
217
+
218
+ # Load all prompt objects that a capability depends on.
219
+ # @param capability [PromptObject] The capability to load dependencies for
220
+ def load_dependencies(capability)
221
+ return unless capability.is_a?(PromptObject)
222
+
223
+ deps = capability.config["capabilities"] || []
224
+ deps.each do |dep_name|
225
+ next if @registry.exists?(dep_name)
226
+
227
+ # Try to load as a prompt object
228
+ path = File.join(@objects_dir, "#{dep_name}.md")
229
+ if File.exist?(path)
230
+ load_prompt_object(path)
231
+ end
232
+ end
233
+ end
234
+
235
+ # Get a capability by name (prompt object or primitive).
236
+ # @param name [String] The capability name
237
+ # @return [Capability, nil]
238
+ def get(name)
239
+ @registry.get(name)
240
+ end
241
+
242
+ # List all loaded prompt objects.
243
+ # @return [Array<String>]
244
+ def loaded_objects
245
+ @registry.prompt_objects.map(&:name)
246
+ end
247
+
248
+ # List all registered primitives.
249
+ # @return [Array<String>]
250
+ def primitives
251
+ @registry.primitives.map(&:name)
252
+ end
253
+
254
+ private
255
+
256
+ # Register built-in primitive capabilities.
257
+ def register_primitives
258
+ @registry.register(Primitives::ReadFile.new)
259
+ @registry.register(Primitives::ListFiles.new)
260
+ @registry.register(Primitives::WriteFile.new)
261
+ @registry.register(Primitives::HttpGet.new)
262
+ end
263
+
264
+ # Register universal capabilities (available to all prompt objects).
265
+ def register_universal_capabilities
266
+ @registry.register(Universal::AskHuman.new)
267
+ @registry.register(Universal::Think.new)
268
+ @registry.register(Universal::CreateCapability.new)
269
+ @registry.register(Universal::AddCapability.new)
270
+ @registry.register(Universal::ListCapabilities.new)
271
+ @registry.register(Universal::ListPrimitives.new)
272
+ @registry.register(Universal::AddPrimitive.new)
273
+ @registry.register(Universal::CreatePrimitive.new)
274
+ @registry.register(Universal::VerifyPrimitive.new)
275
+ @registry.register(Universal::ModifyPrimitive.new)
276
+ @registry.register(Universal::RequestPrimitive.new)
277
+ end
278
+ end
279
+
280
+ # Backwards compatibility alias (Environment was renamed to Runtime).
281
+ # Use Runtime for new code; Environment is preserved for existing callers.
282
+ Environment = Runtime
283
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module PromptObjects
6
+ # Represents a pending request for human input
7
+ class HumanRequest
8
+ attr_reader :id, :capability, :question, :options, :created_at
9
+ attr_accessor :response
10
+
11
+ def initialize(capability:, question:, options: nil)
12
+ @id = SecureRandom.uuid
13
+ @capability = capability
14
+ @question = question
15
+ @options = options
16
+ @created_at = Time.now
17
+ @response = nil
18
+ @mutex = Mutex.new
19
+ @condition = ConditionVariable.new
20
+ end
21
+
22
+ def pending?
23
+ @response.nil?
24
+ end
25
+
26
+ def age
27
+ Time.now - @created_at
28
+ end
29
+
30
+ def age_string
31
+ seconds = age.to_i
32
+ if seconds < 60
33
+ "#{seconds}s"
34
+ elsif seconds < 3600
35
+ "#{seconds / 60}m"
36
+ else
37
+ "#{seconds / 3600}h"
38
+ end
39
+ end
40
+
41
+ # Called by the background thread to wait for response
42
+ def wait_for_response
43
+ @mutex.synchronize do
44
+ @condition.wait(@mutex) while @response.nil?
45
+ @response
46
+ end
47
+ end
48
+
49
+ # Called by the UI thread when human responds
50
+ def respond!(value)
51
+ @mutex.synchronize do
52
+ @response = value
53
+ @condition.broadcast
54
+ end
55
+ end
56
+ end
57
+
58
+ # Queue for managing pending human requests across all POs
59
+ class HumanQueue
60
+ attr_reader :pending
61
+
62
+ def initialize
63
+ @pending = []
64
+ @subscribers = []
65
+ @mutex = Mutex.new
66
+ end
67
+
68
+ # Add a request to the queue
69
+ # Returns the HumanRequest object
70
+ def enqueue(capability:, question:, options: nil)
71
+ request = HumanRequest.new(
72
+ capability: capability,
73
+ question: question,
74
+ options: options
75
+ )
76
+
77
+ @mutex.synchronize do
78
+ @pending << request
79
+ end
80
+
81
+ notify_subscribers(:added, request)
82
+ request
83
+ end
84
+
85
+ # Respond to a pending request by ID
86
+ def respond(request_id, value)
87
+ request = nil
88
+ @mutex.synchronize do
89
+ request = @pending.find { |r| r.id == request_id }
90
+ return unless request
91
+
92
+ @pending.delete(request)
93
+ end
94
+
95
+ notify_subscribers(:resolved, request)
96
+ request.respond!(value)
97
+ end
98
+
99
+ # Get pending requests for a specific capability
100
+ def pending_for(capability_name)
101
+ @mutex.synchronize do
102
+ @pending.select { |r| r.capability == capability_name }
103
+ end
104
+ end
105
+
106
+ # Get count of pending requests per capability
107
+ def pending_counts
108
+ @mutex.synchronize do
109
+ @pending.group_by(&:capability).transform_values(&:count)
110
+ end
111
+ end
112
+
113
+ # Total pending count
114
+ def count
115
+ @mutex.synchronize { @pending.length }
116
+ end
117
+
118
+ # Subscribe to queue events
119
+ # Callback receives (event, request) where event is :added or :resolved
120
+ def subscribe(&block)
121
+ @subscribers << block
122
+ end
123
+
124
+ def unsubscribe(block)
125
+ @subscribers.delete(block)
126
+ end
127
+
128
+ # Get all pending requests (thread-safe copy)
129
+ def all_pending
130
+ @mutex.synchronize { @pending.dup }
131
+ end
132
+
133
+ private
134
+
135
+ def notify_subscribers(event, request)
136
+ @subscribers.each do |s|
137
+ s.call(event, request)
138
+ rescue StandardError => e
139
+ # Don't let subscriber errors break the queue
140
+ warn "HumanQueue subscriber error: #{e.message}"
141
+ end
142
+ end
143
+ end
144
+ end