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.
- checksums.yaml +7 -0
- data/CLAUDE.md +108 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +231 -0
- data/IMPLEMENTATION_PLAN.md +1073 -0
- data/LICENSE +21 -0
- data/README.md +73 -0
- data/Rakefile +27 -0
- data/design-doc-v2.md +1232 -0
- data/exe/prompt_objects +572 -0
- data/exe/prompt_objects_mcp +34 -0
- data/frontend/.gitignore +3 -0
- data/frontend/index.html +13 -0
- data/frontend/package-lock.json +4417 -0
- data/frontend/package.json +32 -0
- data/frontend/postcss.config.js +6 -0
- data/frontend/src/App.tsx +95 -0
- data/frontend/src/components/CapabilitiesPanel.tsx +44 -0
- data/frontend/src/components/ChatPanel.tsx +251 -0
- data/frontend/src/components/Dashboard.tsx +83 -0
- data/frontend/src/components/Header.tsx +141 -0
- data/frontend/src/components/MarkdownMessage.tsx +153 -0
- data/frontend/src/components/MessageBus.tsx +55 -0
- data/frontend/src/components/ModelSelector.tsx +112 -0
- data/frontend/src/components/NotificationPanel.tsx +134 -0
- data/frontend/src/components/POCard.tsx +56 -0
- data/frontend/src/components/PODetail.tsx +117 -0
- data/frontend/src/components/PromptPanel.tsx +51 -0
- data/frontend/src/components/SessionsPanel.tsx +174 -0
- data/frontend/src/components/ThreadsSidebar.tsx +119 -0
- data/frontend/src/components/index.ts +11 -0
- data/frontend/src/hooks/useWebSocket.ts +363 -0
- data/frontend/src/index.css +37 -0
- data/frontend/src/main.tsx +10 -0
- data/frontend/src/store/index.ts +246 -0
- data/frontend/src/types/index.ts +146 -0
- data/frontend/tailwind.config.js +25 -0
- data/frontend/tsconfig.json +30 -0
- data/frontend/vite.config.ts +29 -0
- data/lib/prompt_objects/capability.rb +46 -0
- data/lib/prompt_objects/cli.rb +431 -0
- data/lib/prompt_objects/connectors/base.rb +73 -0
- data/lib/prompt_objects/connectors/mcp.rb +524 -0
- data/lib/prompt_objects/environment/exporter.rb +83 -0
- data/lib/prompt_objects/environment/git.rb +118 -0
- data/lib/prompt_objects/environment/importer.rb +159 -0
- data/lib/prompt_objects/environment/manager.rb +401 -0
- data/lib/prompt_objects/environment/manifest.rb +218 -0
- data/lib/prompt_objects/environment.rb +283 -0
- data/lib/prompt_objects/human_queue.rb +144 -0
- data/lib/prompt_objects/llm/anthropic_adapter.rb +137 -0
- data/lib/prompt_objects/llm/factory.rb +84 -0
- data/lib/prompt_objects/llm/gemini_adapter.rb +209 -0
- data/lib/prompt_objects/llm/openai_adapter.rb +104 -0
- data/lib/prompt_objects/llm/response.rb +61 -0
- data/lib/prompt_objects/loader.rb +32 -0
- data/lib/prompt_objects/mcp/server.rb +167 -0
- data/lib/prompt_objects/mcp/tools/get_conversation.rb +60 -0
- data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +54 -0
- data/lib/prompt_objects/mcp/tools/inspect_po.rb +73 -0
- data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +37 -0
- data/lib/prompt_objects/mcp/tools/respond_to_request.rb +68 -0
- data/lib/prompt_objects/mcp/tools/send_message.rb +71 -0
- data/lib/prompt_objects/message_bus.rb +97 -0
- data/lib/prompt_objects/primitive.rb +13 -0
- data/lib/prompt_objects/primitives/http_get.rb +72 -0
- data/lib/prompt_objects/primitives/list_files.rb +95 -0
- data/lib/prompt_objects/primitives/read_file.rb +81 -0
- data/lib/prompt_objects/primitives/write_file.rb +73 -0
- data/lib/prompt_objects/prompt_object.rb +415 -0
- data/lib/prompt_objects/registry.rb +88 -0
- data/lib/prompt_objects/server/api/routes.rb +297 -0
- data/lib/prompt_objects/server/app.rb +174 -0
- data/lib/prompt_objects/server/file_watcher.rb +113 -0
- data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +77 -0
- data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +1 -0
- data/lib/prompt_objects/server/public/index.html +14 -0
- data/lib/prompt_objects/server/websocket_handler.rb +619 -0
- data/lib/prompt_objects/server.rb +166 -0
- data/lib/prompt_objects/session/store.rb +826 -0
- data/lib/prompt_objects/universal/add_capability.rb +74 -0
- data/lib/prompt_objects/universal/add_primitive.rb +113 -0
- data/lib/prompt_objects/universal/ask_human.rb +109 -0
- data/lib/prompt_objects/universal/create_capability.rb +219 -0
- data/lib/prompt_objects/universal/create_primitive.rb +170 -0
- data/lib/prompt_objects/universal/list_capabilities.rb +55 -0
- data/lib/prompt_objects/universal/list_primitives.rb +145 -0
- data/lib/prompt_objects/universal/modify_primitive.rb +180 -0
- data/lib/prompt_objects/universal/request_primitive.rb +287 -0
- data/lib/prompt_objects/universal/think.rb +41 -0
- data/lib/prompt_objects/universal/verify_primitive.rb +173 -0
- data/lib/prompt_objects.rb +62 -0
- data/objects/coordinator.md +48 -0
- data/objects/greeter.md +30 -0
- data/objects/reader.md +33 -0
- data/prompt_objects.gemspec +50 -0
- data/templates/basic/.gitignore +2 -0
- data/templates/basic/manifest.yml +7 -0
- data/templates/basic/objects/basic.md +32 -0
- data/templates/developer/.gitignore +5 -0
- data/templates/developer/manifest.yml +17 -0
- data/templates/developer/objects/code_reviewer.md +33 -0
- data/templates/developer/objects/coordinator.md +39 -0
- data/templates/developer/objects/debugger.md +35 -0
- data/templates/empty/.gitignore +5 -0
- data/templates/empty/manifest.yml +14 -0
- data/templates/empty/objects/.gitkeep +0 -0
- data/templates/empty/objects/assistant.md +41 -0
- data/templates/minimal/.gitignore +5 -0
- data/templates/minimal/manifest.yml +7 -0
- data/templates/minimal/objects/assistant.md +41 -0
- data/templates/writer/.gitignore +5 -0
- data/templates/writer/manifest.yml +17 -0
- data/templates/writer/objects/coordinator.md +33 -0
- data/templates/writer/objects/editor.md +33 -0
- data/templates/writer/objects/researcher.md +34 -0
- 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
|