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,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack"
5
+
6
+ module PromptObjects
7
+ module Server
8
+ module API
9
+ # REST API routes for the PromptObjects server.
10
+ # Provides endpoints for listing POs, sessions, and environment info.
11
+ class Routes
12
+ def initialize(runtime)
13
+ @runtime = runtime
14
+ end
15
+
16
+ def call(env)
17
+ request = Rack::Request.new(env)
18
+ path = request.path_info.sub("/api", "")
19
+
20
+ response = route(request, path)
21
+ json_response(response)
22
+ rescue => e
23
+ json_response({ error: e.message }, status: 500)
24
+ end
25
+
26
+ private
27
+
28
+ def route(request, path)
29
+ method = request.request_method
30
+
31
+ case [method, path]
32
+
33
+ # Environment
34
+ when ["GET", "/environment"]
35
+ get_environment
36
+
37
+ # Prompt Objects
38
+ when ["GET", "/prompt_objects"]
39
+ list_prompt_objects
40
+
41
+ when ["GET", %r{^/prompt_objects/([^/]+)$}]
42
+ get_prompt_object(path_param(path, 1))
43
+
44
+ when ["POST", "/prompt_objects"]
45
+ create_prompt_object(request.body.read)
46
+
47
+ # Sessions
48
+ when ["GET", %r{^/prompt_objects/([^/]+)/sessions$}]
49
+ list_sessions(path_param(path, 1))
50
+
51
+ when ["GET", %r{^/prompt_objects/([^/]+)/sessions/([^/]+)$}]
52
+ get_session(path_param(path, 1), path_param(path, 2))
53
+
54
+ when ["POST", %r{^/prompt_objects/([^/]+)/sessions$}]
55
+ create_session(path_param(path, 1), request.body.read)
56
+
57
+ # Primitives
58
+ when ["GET", "/primitives"]
59
+ list_primitives
60
+
61
+ # Message Bus
62
+ when ["GET", "/bus/recent"]
63
+ get_recent_bus_messages
64
+
65
+ else
66
+ { error: "Not found", path: path }
67
+ end
68
+ end
69
+
70
+ # === Environment ===
71
+
72
+ def get_environment
73
+ manifest_data = if @runtime.manifest
74
+ {
75
+ name: @runtime.manifest.name,
76
+ description: @runtime.manifest.description,
77
+ icon: @runtime.manifest.icon,
78
+ created_at: @runtime.manifest.created_at&.iso8601,
79
+ last_opened: @runtime.manifest.last_opened&.iso8601
80
+ }
81
+ end
82
+
83
+ {
84
+ name: @runtime.name,
85
+ path: @runtime.env_path,
86
+ prompt_object_count: @runtime.registry.prompt_objects.size,
87
+ primitive_count: @runtime.registry.primitives.size,
88
+ manifest: manifest_data
89
+ }
90
+ end
91
+
92
+ # === Prompt Objects ===
93
+
94
+ def list_prompt_objects
95
+ pos = @runtime.registry.prompt_objects.map do |po|
96
+ po_summary(po)
97
+ end
98
+
99
+ { prompt_objects: pos }
100
+ end
101
+
102
+ def get_prompt_object(name)
103
+ po = @runtime.registry.get(name)
104
+
105
+ unless po.is_a?(PromptObject)
106
+ return { error: "Not found", name: name }
107
+ end
108
+
109
+ po_full(po)
110
+ end
111
+
112
+ def create_prompt_object(body)
113
+ # TODO: Implement PO creation via API
114
+ { error: "Not implemented" }
115
+ end
116
+
117
+ # === Sessions ===
118
+
119
+ def list_sessions(po_name)
120
+ po = @runtime.registry.get(po_name)
121
+
122
+ unless po.is_a?(PromptObject)
123
+ return { error: "Not found", name: po_name }
124
+ end
125
+
126
+ sessions = po.list_sessions.map do |s|
127
+ {
128
+ id: s[:id],
129
+ name: s[:name],
130
+ message_count: s[:message_count] || 0,
131
+ created_at: s[:created_at]&.iso8601,
132
+ updated_at: s[:updated_at]&.iso8601
133
+ }
134
+ end
135
+
136
+ { sessions: sessions }
137
+ end
138
+
139
+ def get_session(po_name, session_id)
140
+ po = @runtime.registry.get(po_name)
141
+
142
+ unless po.is_a?(PromptObject)
143
+ return { error: "Prompt object not found", name: po_name }
144
+ end
145
+
146
+ return { error: "No session store" } unless @runtime.session_store
147
+
148
+ session = @runtime.session_store.get_session(session_id)
149
+
150
+ unless session && session[:po_name] == po_name
151
+ return { error: "Session not found", id: session_id }
152
+ end
153
+
154
+ messages = @runtime.session_store.get_messages(session_id)
155
+
156
+ {
157
+ id: session[:id],
158
+ name: session[:name],
159
+ po_name: session[:po_name],
160
+ messages: messages.map { |m| format_message(m) },
161
+ created_at: session[:created_at]&.iso8601,
162
+ updated_at: session[:updated_at]&.iso8601
163
+ }
164
+ end
165
+
166
+ def create_session(po_name, body)
167
+ po = @runtime.registry.get(po_name)
168
+
169
+ unless po.is_a?(PromptObject)
170
+ return { error: "Not found", name: po_name }
171
+ end
172
+
173
+ params = body.empty? ? {} : JSON.parse(body)
174
+ session_name = params["name"]
175
+
176
+ session_id = po.new_session(name: session_name)
177
+
178
+ {
179
+ success: true,
180
+ session_id: session_id,
181
+ name: session_name
182
+ }
183
+ end
184
+
185
+ # === Primitives ===
186
+
187
+ def list_primitives
188
+ primitives = @runtime.registry.primitives.map do |p|
189
+ {
190
+ name: p.name,
191
+ description: p.description
192
+ }
193
+ end
194
+
195
+ { primitives: primitives }
196
+ end
197
+
198
+ # === Message Bus ===
199
+
200
+ def get_recent_bus_messages
201
+ entries = @runtime.bus.recent(50)
202
+
203
+ messages = entries.map do |e|
204
+ {
205
+ from: e[:from],
206
+ to: e[:to],
207
+ message: e[:message],
208
+ timestamp: e[:timestamp].iso8601
209
+ }
210
+ end
211
+
212
+ { messages: messages }
213
+ end
214
+
215
+ # === Helpers ===
216
+
217
+ def po_summary(po)
218
+ {
219
+ name: po.name,
220
+ description: po.description,
221
+ capabilities: po.config["capabilities"] || [],
222
+ session_count: po.list_sessions.size
223
+ }
224
+ end
225
+
226
+ def po_full(po)
227
+ {
228
+ name: po.name,
229
+ description: po.description,
230
+ capabilities: po.config["capabilities"] || [],
231
+ body: po.body,
232
+ config: po.config,
233
+ sessions: po.list_sessions.map do |s|
234
+ {
235
+ id: s[:id],
236
+ name: s[:name],
237
+ message_count: s[:message_count] || 0
238
+ }
239
+ end,
240
+ current_session: po.session_id,
241
+ history: po.history.map { |m| format_history_message(m) }
242
+ }
243
+ end
244
+
245
+ def format_message(msg)
246
+ {
247
+ role: msg[:role].to_s,
248
+ content: msg[:content],
249
+ from_po: msg[:from_po],
250
+ tool_calls: msg[:tool_calls],
251
+ tool_results: msg[:tool_results],
252
+ created_at: msg[:created_at]&.iso8601
253
+ }.compact
254
+ end
255
+
256
+ def format_history_message(msg)
257
+ case msg[:role]
258
+ when :user
259
+ { role: "user", content: msg[:content], from: msg[:from] }
260
+ when :assistant
261
+ h = { role: "assistant", content: msg[:content] }
262
+ if msg[:tool_calls]
263
+ h[:tool_calls] = msg[:tool_calls].map do |tc|
264
+ { id: tc.id, name: tc.name, arguments: tc.arguments }
265
+ end
266
+ end
267
+ h
268
+ when :tool
269
+ { role: "tool", results: msg[:results] }
270
+ else
271
+ { role: msg[:role].to_s, content: msg[:content] }
272
+ end
273
+ end
274
+
275
+ def path_param(path, index)
276
+ # Extract path parameter from regex match
277
+ # /prompt_objects/foo -> foo (index 1)
278
+ # /prompt_objects/foo/sessions/bar -> foo (1), bar (2)
279
+ parts = path.split("/").reject(&:empty?)
280
+ parts[index]
281
+ end
282
+
283
+ def json_response(data, status: 200)
284
+ body = JSON.generate(data)
285
+ [
286
+ status,
287
+ {
288
+ "content-type" => "application/json",
289
+ "access-control-allow-origin" => "*"
290
+ },
291
+ [body]
292
+ ]
293
+ end
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/websocket/adapters/rack"
4
+ require "json"
5
+ require "rack"
6
+
7
+ module PromptObjects
8
+ module Server
9
+ # Main Rack application for the PromptObjects web server.
10
+ # Routes requests to WebSocket handler, API, or static assets.
11
+ class App
12
+ STATIC_EXTENSIONS = %w[.html .js .css .png .svg .ico .woff .woff2 .map .json].freeze
13
+
14
+ attr_reader :connections
15
+
16
+ def initialize(runtime)
17
+ @runtime = runtime
18
+ @api = API::Routes.new(runtime)
19
+ @public_path = File.expand_path("public", __dir__)
20
+ @connections = []
21
+ @connections_mutex = Mutex.new
22
+ end
23
+
24
+ def call(env)
25
+ request_path = env["PATH_INFO"]
26
+
27
+ if websocket_request?(env)
28
+ handle_websocket(env)
29
+ elsif request_path.start_with?("/api/")
30
+ @api.call(env)
31
+ elsif static_asset?(request_path)
32
+ serve_static(request_path)
33
+ else
34
+ serve_index
35
+ end
36
+ end
37
+
38
+ # Broadcast a message to all connected WebSocket clients.
39
+ # @param message [Hash] Message to broadcast
40
+ def broadcast(message)
41
+ @connections_mutex.synchronize do
42
+ @connections.each do |handler|
43
+ handler.send_message(message)
44
+ rescue StandardError => e
45
+ puts "Broadcast error: #{e.message}" if ENV["DEBUG"]
46
+ end
47
+ end
48
+ end
49
+
50
+ # Register a connection handler.
51
+ def register_connection(handler)
52
+ @connections_mutex.synchronize do
53
+ @connections << handler
54
+ end
55
+ end
56
+
57
+ # Unregister a connection handler.
58
+ def unregister_connection(handler)
59
+ @connections_mutex.synchronize do
60
+ @connections.delete(handler)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def websocket_request?(env)
67
+ env["HTTP_UPGRADE"]&.downcase == "websocket"
68
+ end
69
+
70
+ def handle_websocket(env)
71
+ Async::WebSocket::Adapters::Rack.open(env, protocols: ["json"]) do |connection|
72
+ handler = WebSocketHandler.new(
73
+ runtime: @runtime,
74
+ connection: connection,
75
+ app: self
76
+ )
77
+
78
+ register_connection(handler)
79
+ begin
80
+ handler.run
81
+ ensure
82
+ unregister_connection(handler)
83
+ end
84
+ end
85
+ end
86
+
87
+ def static_asset?(path)
88
+ STATIC_EXTENSIONS.any? { |ext| path.end_with?(ext) }
89
+ end
90
+
91
+ def serve_static(path)
92
+ # Security: prevent directory traversal
93
+ safe_path = File.expand_path(File.join(@public_path, path))
94
+ unless safe_path.start_with?(@public_path)
95
+ return [403, { "content-type" => "text/plain" }, ["Forbidden"]]
96
+ end
97
+
98
+ if File.exist?(safe_path) && File.file?(safe_path)
99
+ content_type = content_type_for(path)
100
+ body = File.read(safe_path)
101
+ [200, { "content-type" => content_type }, [body]]
102
+ else
103
+ [404, { "content-type" => "text/plain" }, ["Not found"]]
104
+ end
105
+ end
106
+
107
+ def serve_index
108
+ index_path = File.join(@public_path, "index.html")
109
+
110
+ if File.exist?(index_path)
111
+ body = File.read(index_path)
112
+ [200, { "content-type" => "text/html" }, [body]]
113
+ else
114
+ # If no frontend is built yet, serve a placeholder
115
+ [200, { "content-type" => "text/html" }, [placeholder_html]]
116
+ end
117
+ end
118
+
119
+ def content_type_for(path)
120
+ case File.extname(path)
121
+ when ".html" then "text/html"
122
+ when ".js" then "application/javascript"
123
+ when ".css" then "text/css"
124
+ when ".json" then "application/json"
125
+ when ".svg" then "image/svg+xml"
126
+ when ".png" then "image/png"
127
+ when ".ico" then "image/x-icon"
128
+ when ".woff" then "font/woff"
129
+ when ".woff2" then "font/woff2"
130
+ when ".map" then "application/json"
131
+ else "application/octet-stream"
132
+ end
133
+ end
134
+
135
+ def placeholder_html
136
+ <<~HTML
137
+ <!DOCTYPE html>
138
+ <html>
139
+ <head>
140
+ <title>PromptObjects</title>
141
+ <style>
142
+ body {
143
+ font-family: system-ui, -apple-system, sans-serif;
144
+ max-width: 600px;
145
+ margin: 100px auto;
146
+ padding: 20px;
147
+ background: #1a1a2e;
148
+ color: #eee;
149
+ }
150
+ h1 { color: #7c3aed; }
151
+ code {
152
+ background: #2d2d44;
153
+ padding: 2px 6px;
154
+ border-radius: 4px;
155
+ }
156
+ .status { color: #22c55e; }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <h1>PromptObjects</h1>
161
+ <p class="status">Server is running</p>
162
+ <p>Environment: <code>#{@runtime.name}</code></p>
163
+ <p>Prompt Objects: <code>#{@runtime.registry.prompt_objects.size}</code></p>
164
+ <p>WebSocket endpoint: <code>ws://localhost:PORT/</code></p>
165
+ <hr>
166
+ <p>The React frontend has not been built yet.</p>
167
+ <p>For now, you can connect via WebSocket to interact with POs.</p>
168
+ </body>
169
+ </html>
170
+ HTML
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "listen"
4
+
5
+ module PromptObjects
6
+ module Server
7
+ # Watches the environment directory for file changes and notifies subscribers.
8
+ # This enables live updates when POs are created/modified/deleted.
9
+ class FileWatcher
10
+ def initialize(runtime:, env_path:)
11
+ @runtime = runtime
12
+ @env_path = env_path
13
+ @subscribers = []
14
+ @listener = nil
15
+ end
16
+
17
+ def start
18
+ objects_dir = File.join(@env_path, "objects")
19
+
20
+ unless Dir.exist?(objects_dir)
21
+ puts "FileWatcher: objects directory not found at #{objects_dir}"
22
+ return
23
+ end
24
+
25
+ @listener = Listen.to(objects_dir, only: /\.md$/) do |modified, added, removed|
26
+ handle_changes(modified: modified, added: added, removed: removed)
27
+ end
28
+
29
+ @listener.start
30
+ puts "FileWatcher: watching #{objects_dir} for changes"
31
+ end
32
+
33
+ def stop
34
+ @listener&.stop
35
+ end
36
+
37
+ def subscribe(&block)
38
+ @subscribers << block
39
+ end
40
+
41
+ def unsubscribe(block)
42
+ @subscribers.delete(block)
43
+ end
44
+
45
+ private
46
+
47
+ def handle_changes(modified:, added:, removed:)
48
+ # Handle added files
49
+ added.each { |path| handle_po_added(path) }
50
+
51
+ # Handle modified files
52
+ modified.each { |path| handle_po_modified(path) }
53
+
54
+ # Handle removed files
55
+ removed.each { |path| handle_po_removed(path) }
56
+ end
57
+
58
+ def handle_po_added(path)
59
+ name = File.basename(path, ".md")
60
+
61
+ # Skip if already loaded (e.g., by create_capability)
62
+ # The on_po_registered callback will have already broadcast
63
+ if @runtime.registry.exists?(name)
64
+ puts "FileWatcher: PO already loaded - #{name} (skipping)"
65
+ return
66
+ end
67
+
68
+ puts "FileWatcher: PO added - #{name}"
69
+
70
+ begin
71
+ po = @runtime.load_prompt_object(path)
72
+ @runtime.load_dependencies(po)
73
+ notify(:po_added, po)
74
+ rescue StandardError => e
75
+ puts "FileWatcher: Failed to load #{name}: #{e.message}"
76
+ end
77
+ end
78
+
79
+ def handle_po_modified(path)
80
+ name = File.basename(path, ".md")
81
+ puts "FileWatcher: PO modified - #{name}"
82
+
83
+ begin
84
+ # Remove the old version from registry
85
+ @runtime.registry.unregister(name)
86
+
87
+ # Load the new version
88
+ po = @runtime.load_prompt_object(path)
89
+ @runtime.load_dependencies(po)
90
+ notify(:po_modified, po)
91
+ rescue StandardError => e
92
+ puts "FileWatcher: Failed to reload #{name}: #{e.message}"
93
+ end
94
+ end
95
+
96
+ def handle_po_removed(path)
97
+ name = File.basename(path, ".md")
98
+ puts "FileWatcher: PO removed - #{name}"
99
+
100
+ removed = @runtime.registry.unregister(name)
101
+ notify(:po_removed, { name: name }) if removed
102
+ end
103
+
104
+ def notify(event, data)
105
+ @subscribers.each do |subscriber|
106
+ subscriber.call(event, data)
107
+ rescue StandardError => e
108
+ puts "FileWatcher subscriber error: #{e.message}"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end