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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module PromptObjects
6
+ module MCP
7
+ # MCP Server exposing PromptObjects functionality
8
+ # This allows any MCP client (Claude Desktop, Go TUI, etc.) to interact with POs
9
+ class Server
10
+ attr_reader :env, :mcp_server
11
+
12
+ def initialize(objects_dir: "objects", primitives_dir: nil)
13
+ @objects_dir = objects_dir
14
+ @primitives_dir = primitives_dir
15
+ @env = nil
16
+ @context = nil
17
+ end
18
+
19
+ def start
20
+ setup_environment
21
+ setup_mcp_server
22
+ run_stdio_transport
23
+ end
24
+
25
+ private
26
+
27
+ def setup_environment
28
+ @env = Environment.new(
29
+ objects_dir: @objects_dir,
30
+ primitives_dir: @primitives_dir
31
+ )
32
+ @context = @env.context(tui_mode: true)
33
+
34
+ load_all_objects
35
+ end
36
+
37
+ def load_all_objects
38
+ return unless Dir.exist?(@objects_dir)
39
+
40
+ Dir.glob(File.join(@objects_dir, "*.md")).each do |path|
41
+ @env.load_prompt_object(path)
42
+ rescue StandardError => e
43
+ warn "Failed to load #{path}: #{e.message}"
44
+ end
45
+
46
+ @env.registry.prompt_objects.each do |po|
47
+ @env.load_dependencies(po)
48
+ end
49
+ end
50
+
51
+ def setup_mcp_server
52
+ @mcp_server = ::MCP::Server.new(
53
+ name: "prompt_objects",
54
+ version: "0.1.0",
55
+ tools: build_tools,
56
+ server_context: { env: @env, context: @context }
57
+ )
58
+
59
+ setup_resource_handlers
60
+ end
61
+
62
+ def build_tools
63
+ [
64
+ Tools::ListPromptObjects,
65
+ Tools::SendMessage,
66
+ Tools::GetConversation,
67
+ Tools::GetPendingRequests,
68
+ Tools::RespondToRequest,
69
+ Tools::InspectPO
70
+ ]
71
+ end
72
+
73
+ def setup_resource_handlers
74
+ @mcp_server.resources_read_handler do |params|
75
+ handle_resource_read(params)
76
+ end
77
+ end
78
+
79
+ def handle_resource_read(params)
80
+ uri = params[:uri]
81
+
82
+ case uri
83
+ when %r{^po://([^/]+)/conversation$}
84
+ po_name = ::Regexp.last_match(1)
85
+ read_conversation(po_name)
86
+ when %r{^po://([^/]+)/config$}
87
+ po_name = ::Regexp.last_match(1)
88
+ read_config(po_name)
89
+ when %r{^po://([^/]+)/prompt$}
90
+ po_name = ::Regexp.last_match(1)
91
+ read_prompt(po_name)
92
+ when "bus://messages"
93
+ read_bus_messages
94
+ else
95
+ [{ uri: uri, mimeType: "text/plain", text: "Unknown resource: #{uri}" }]
96
+ end
97
+ end
98
+
99
+ def read_conversation(po_name)
100
+ po = @env.registry.get(po_name)
101
+ return [{ uri: "po://#{po_name}/conversation", mimeType: "text/plain", text: "PO not found" }] unless po
102
+
103
+ history = po.history.map do |msg|
104
+ { role: msg[:role].to_s, content: msg[:content] }
105
+ end
106
+
107
+ [{
108
+ uri: "po://#{po_name}/conversation",
109
+ mimeType: "application/json",
110
+ text: JSON.pretty_generate(history)
111
+ }]
112
+ end
113
+
114
+ def read_config(po_name)
115
+ po = @env.registry.get(po_name)
116
+ return [{ uri: "po://#{po_name}/config", mimeType: "text/plain", text: "PO not found" }] unless po
117
+
118
+ [{
119
+ uri: "po://#{po_name}/config",
120
+ mimeType: "application/json",
121
+ text: JSON.pretty_generate(po.config)
122
+ }]
123
+ end
124
+
125
+ def read_prompt(po_name)
126
+ po = @env.registry.get(po_name)
127
+ return [{ uri: "po://#{po_name}/prompt", mimeType: "text/plain", text: "PO not found" }] unless po
128
+
129
+ [{
130
+ uri: "po://#{po_name}/prompt",
131
+ mimeType: "text/markdown",
132
+ text: po.body
133
+ }]
134
+ end
135
+
136
+ def read_bus_messages
137
+ entries = @env.bus.recent(50).map do |entry|
138
+ {
139
+ from: entry[:from],
140
+ to: entry[:to],
141
+ message: entry[:message],
142
+ timestamp: entry[:timestamp]&.iso8601
143
+ }
144
+ end
145
+
146
+ [{
147
+ uri: "bus://messages",
148
+ mimeType: "application/json",
149
+ text: JSON.pretty_generate(entries)
150
+ }]
151
+ end
152
+
153
+ def run_stdio_transport
154
+ transport = ::MCP::Server::Transports::StdioTransport.new(@mcp_server)
155
+ transport.open
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ # Require tools
162
+ require_relative "tools/list_prompt_objects"
163
+ require_relative "tools/send_message"
164
+ require_relative "tools/get_conversation"
165
+ require_relative "tools/get_pending_requests"
166
+ require_relative "tools/respond_to_request"
167
+ require_relative "tools/inspect_po"
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module MCP
5
+ module Tools
6
+ # Get conversation history for a prompt object
7
+ class GetConversation < ::MCP::Tool
8
+ tool_name "get_conversation"
9
+ description "Get the conversation history for a prompt object"
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {
14
+ po_name: {
15
+ type: "string",
16
+ description: "Name of the prompt object"
17
+ },
18
+ limit: {
19
+ type: "integer",
20
+ description: "Maximum number of messages to return (default: all)"
21
+ }
22
+ },
23
+ required: %w[po_name]
24
+ )
25
+
26
+ def self.call(po_name:, limit: nil, server_context:)
27
+ env = server_context[:env]
28
+
29
+ po = env.registry.get(po_name)
30
+ unless po.is_a?(PromptObjects::PromptObject)
31
+ return ::MCP::Tool::Response.new([{
32
+ type: "text",
33
+ text: JSON.generate({ error: "Prompt object '#{po_name}' not found" })
34
+ }])
35
+ end
36
+
37
+ history = po.history.map do |msg|
38
+ {
39
+ role: msg[:role].to_s,
40
+ content: msg[:content],
41
+ from: msg[:from],
42
+ tool_calls: msg[:tool_calls]&.map { |tc| { name: tc.name, arguments: tc.arguments } }
43
+ }.compact
44
+ end
45
+
46
+ history = history.last(limit) if limit
47
+
48
+ ::MCP::Tool::Response.new([{
49
+ type: "text",
50
+ text: JSON.pretty_generate({
51
+ po_name: po_name,
52
+ message_count: history.length,
53
+ history: history
54
+ })
55
+ }])
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module MCP
5
+ module Tools
6
+ # Get all pending human requests from the queue
7
+ class GetPendingRequests < ::MCP::Tool
8
+ tool_name "get_pending_requests"
9
+ description "Get all pending human requests across all prompt objects. These are questions POs have asked that await human response."
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {
14
+ po_name: {
15
+ type: "string",
16
+ description: "Optional: filter to requests from a specific PO"
17
+ }
18
+ },
19
+ required: []
20
+ )
21
+
22
+ def self.call(po_name: nil, server_context:)
23
+ env = server_context[:env]
24
+ queue = env.human_queue
25
+
26
+ requests = if po_name
27
+ queue.pending_for(po_name)
28
+ else
29
+ queue.all_pending
30
+ end
31
+
32
+ formatted = requests.map do |req|
33
+ {
34
+ id: req.id,
35
+ capability: req.capability,
36
+ question: req.question,
37
+ options: req.options,
38
+ age: req.age_string,
39
+ created_at: req.created_at.iso8601
40
+ }
41
+ end
42
+
43
+ ::MCP::Tool::Response.new([{
44
+ type: "text",
45
+ text: JSON.pretty_generate({
46
+ count: formatted.length,
47
+ requests: formatted
48
+ })
49
+ }])
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module MCP
5
+ module Tools
6
+ # Get detailed information about a prompt object
7
+ class InspectPO < ::MCP::Tool
8
+ tool_name "inspect_po"
9
+ description "Get detailed information about a prompt object including its configuration, capabilities, and prompt body"
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {
14
+ po_name: {
15
+ type: "string",
16
+ description: "Name of the prompt object to inspect"
17
+ }
18
+ },
19
+ required: %w[po_name]
20
+ )
21
+
22
+ def self.call(po_name:, server_context:)
23
+ env = server_context[:env]
24
+
25
+ po = env.registry.get(po_name)
26
+ unless po.is_a?(PromptObjects::PromptObject)
27
+ return ::MCP::Tool::Response.new([{
28
+ type: "text",
29
+ text: JSON.generate({ error: "Prompt object '#{po_name}' not found" })
30
+ }])
31
+ end
32
+
33
+ # Categorize capabilities
34
+ declared_caps = po.config["capabilities"] || []
35
+ universal_caps = PromptObjects::UNIVERSAL_CAPABILITIES
36
+
37
+ # Resolve which are POs vs primitives
38
+ delegates = []
39
+ primitives = []
40
+
41
+ declared_caps.each do |cap_name|
42
+ cap = env.registry.get(cap_name)
43
+ if cap.is_a?(PromptObjects::PromptObject)
44
+ delegates << cap_name
45
+ elsif cap.is_a?(PromptObjects::Primitive)
46
+ primitives << cap_name
47
+ end
48
+ end
49
+
50
+ info = {
51
+ name: po.name,
52
+ description: po.description,
53
+ state: po.state || :idle,
54
+ config: po.config,
55
+ capabilities: {
56
+ universal: universal_caps,
57
+ primitives: primitives,
58
+ delegates: delegates,
59
+ all_declared: declared_caps
60
+ },
61
+ prompt_body: po.body,
62
+ history_length: po.history.length
63
+ }
64
+
65
+ ::MCP::Tool::Response.new([{
66
+ type: "text",
67
+ text: JSON.pretty_generate(info)
68
+ }])
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module MCP
5
+ module Tools
6
+ # List all loaded prompt objects with their current state
7
+ class ListPromptObjects < ::MCP::Tool
8
+ tool_name "list_prompt_objects"
9
+ description "List all loaded prompt objects with their names, descriptions, and current state"
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {},
14
+ required: []
15
+ )
16
+
17
+ def self.call(server_context:, **_args)
18
+ env = server_context[:env]
19
+
20
+ pos = env.registry.prompt_objects.map do |po|
21
+ {
22
+ name: po.name,
23
+ description: po.description,
24
+ state: po.state || :idle,
25
+ capabilities: po.config["capabilities"] || []
26
+ }
27
+ end
28
+
29
+ ::MCP::Tool::Response.new([{
30
+ type: "text",
31
+ text: JSON.pretty_generate(pos)
32
+ }])
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module MCP
5
+ module Tools
6
+ # Respond to a pending human request
7
+ class RespondToRequest < ::MCP::Tool
8
+ tool_name "respond_to_request"
9
+ description "Respond to a pending human request from a prompt object. This unblocks the PO that asked the question."
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {
14
+ request_id: {
15
+ type: "string",
16
+ description: "The ID of the pending request (from get_pending_requests)"
17
+ },
18
+ response: {
19
+ type: "string",
20
+ description: "Your response to the question"
21
+ }
22
+ },
23
+ required: %w[request_id response]
24
+ )
25
+
26
+ def self.call(request_id:, response:, server_context:)
27
+ env = server_context[:env]
28
+ queue = env.human_queue
29
+
30
+ # Find the request first to give better error messages
31
+ request = queue.all_pending.find { |r| r.id == request_id }
32
+
33
+ unless request
34
+ return ::MCP::Tool::Response.new([{
35
+ type: "text",
36
+ text: JSON.generate({
37
+ error: "Request not found",
38
+ request_id: request_id,
39
+ hint: "Use get_pending_requests to see available requests"
40
+ })
41
+ }])
42
+ end
43
+
44
+ # Respond (this unblocks the waiting thread)
45
+ queue.respond(request_id, response)
46
+
47
+ # Log to message bus
48
+ env.bus.publish(
49
+ from: "mcp_client",
50
+ to: request.capability,
51
+ message: "[response to ask_human] #{response}"
52
+ )
53
+
54
+ ::MCP::Tool::Response.new([{
55
+ type: "text",
56
+ text: JSON.generate({
57
+ success: true,
58
+ request_id: request_id,
59
+ capability: request.capability,
60
+ question: request.question,
61
+ response: response
62
+ })
63
+ }])
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module MCP
5
+ module Tools
6
+ # Send a message to a prompt object and get its response
7
+ class SendMessage < ::MCP::Tool
8
+ tool_name "send_message"
9
+ description "Send a message to a prompt object. The PO will process it (potentially calling tools) and return a response."
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {
14
+ po_name: {
15
+ type: "string",
16
+ description: "Name of the prompt object to message"
17
+ },
18
+ message: {
19
+ type: "string",
20
+ description: "The message to send"
21
+ }
22
+ },
23
+ required: %w[po_name message]
24
+ )
25
+
26
+ def self.call(po_name:, message:, server_context:)
27
+ env = server_context[:env]
28
+ context = server_context[:context]
29
+
30
+ po = env.registry.get(po_name)
31
+ unless po.is_a?(PromptObjects::PromptObject)
32
+ return ::MCP::Tool::Response.new([{
33
+ type: "text",
34
+ text: JSON.generate({ error: "Prompt object '#{po_name}' not found" })
35
+ }])
36
+ end
37
+
38
+ # Log to message bus
39
+ env.bus.publish(from: "mcp_client", to: po_name, message: message)
40
+
41
+ # Set context for this interaction
42
+ context.current_capability = po_name
43
+ po.state = :working
44
+
45
+ begin
46
+ response = po.receive(message, context: context)
47
+ po.state = :idle
48
+
49
+ # Log response
50
+ env.bus.publish(from: po_name, to: "mcp_client", message: response)
51
+
52
+ ::MCP::Tool::Response.new([{
53
+ type: "text",
54
+ text: JSON.generate({
55
+ po_name: po_name,
56
+ response: response,
57
+ history_length: po.history.length
58
+ })
59
+ }])
60
+ rescue StandardError => e
61
+ po.state = :idle
62
+ ::MCP::Tool::Response.new([{
63
+ type: "text",
64
+ text: JSON.generate({ error: e.message, backtrace: e.backtrace.first(5) })
65
+ }])
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ # Message bus for routing and logging all inter-capability communication.
5
+ # This makes the semantic binding visible - you can see natural language
6
+ # being transformed into capability calls.
7
+ class MessageBus
8
+ attr_reader :log
9
+
10
+ def initialize
11
+ @log = []
12
+ @subscribers = []
13
+ end
14
+
15
+ # Log a message between capabilities.
16
+ # @param from [String] Source capability name
17
+ # @param to [String] Destination capability name
18
+ # @param message [String, Hash] The message content
19
+ # @return [Hash] The log entry
20
+ def publish(from:, to:, message:)
21
+ entry = {
22
+ timestamp: Time.now,
23
+ from: from,
24
+ to: to,
25
+ message: truncate_message(message)
26
+ }
27
+
28
+ @log << entry
29
+ notify_subscribers(entry)
30
+ entry
31
+ end
32
+
33
+ # Subscribe to message events.
34
+ # @yield [Hash] Called with each new message entry
35
+ def subscribe(&block)
36
+ @subscribers << block
37
+ end
38
+
39
+ # Unsubscribe from message events.
40
+ # @param block [Proc] The subscriber to remove
41
+ def unsubscribe(block)
42
+ @subscribers.delete(block)
43
+ end
44
+
45
+ # Get recent log entries.
46
+ # @param count [Integer] Number of entries to return
47
+ # @return [Array<Hash>]
48
+ def recent(count = 20)
49
+ @log.last(count)
50
+ end
51
+
52
+ # Clear the log.
53
+ def clear
54
+ @log.clear
55
+ end
56
+
57
+ # Format log entries for display.
58
+ # @param count [Integer] Number of entries to format
59
+ # @return [String]
60
+ def format_log(count = 20)
61
+ recent(count).map do |entry|
62
+ time = entry[:timestamp].strftime("%H:%M:%S")
63
+ from = entry[:from]
64
+ to = entry[:to]
65
+ msg = entry[:message]
66
+
67
+ "#{time} #{from} → #{to}: #{msg}"
68
+ end.join("\n")
69
+ end
70
+
71
+ private
72
+
73
+ def notify_subscribers(entry)
74
+ @subscribers.each { |s| s.call(entry) }
75
+ end
76
+
77
+ def truncate_message(message, max_length = 100)
78
+ str = case message
79
+ when Hash
80
+ message.to_json
81
+ when String
82
+ message
83
+ else
84
+ message.to_s
85
+ end
86
+
87
+ # Remove newlines for cleaner log display
88
+ str = str.gsub(/\s+/, " ").strip
89
+
90
+ if str.length > max_length
91
+ str[0, max_length] + "..."
92
+ else
93
+ str
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ # Base class for primitive capabilities.
5
+ # Primitives are deterministic Ruby implementations (no LLM interpretation).
6
+ class Primitive < Capability
7
+ # Primitives are always "idle" in terms of state since they execute synchronously
8
+ def initialize
9
+ super
10
+ @state = :idle
11
+ end
12
+ end
13
+ end