aia 0.5.18 → 0.8.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.version +1 -1
  4. data/CHANGELOG.md +39 -5
  5. data/README.md +388 -219
  6. data/Rakefile +16 -5
  7. data/_notes.txt +231 -0
  8. data/bin/aia +3 -2
  9. data/examples/README.md +140 -0
  10. data/examples/headlines +21 -0
  11. data/lib/aia/ai_client_adapter.rb +210 -0
  12. data/lib/aia/chat_processor_service.rb +120 -0
  13. data/lib/aia/config.rb +473 -4
  14. data/lib/aia/context_manager.rb +58 -0
  15. data/lib/aia/directive_processor.rb +267 -0
  16. data/lib/aia/{tools/fzf.rb → fzf.rb} +9 -17
  17. data/lib/aia/history_manager.rb +85 -0
  18. data/lib/aia/prompt_handler.rb +178 -0
  19. data/lib/aia/session.rb +215 -0
  20. data/lib/aia/shell_command_executor.rb +109 -0
  21. data/lib/aia/ui_presenter.rb +110 -0
  22. data/lib/aia/utility.rb +24 -0
  23. data/lib/aia/version.rb +9 -6
  24. data/lib/aia.rb +57 -61
  25. data/lib/extensions/openstruct_merge.rb +44 -0
  26. metadata +29 -42
  27. data/LICENSE.txt +0 -21
  28. data/doc/aia_and_pre_compositional_prompts.md +0 -474
  29. data/lib/aia/clause.rb +0 -7
  30. data/lib/aia/cli.rb +0 -452
  31. data/lib/aia/directives.rb +0 -142
  32. data/lib/aia/dynamic_content.rb +0 -26
  33. data/lib/aia/logging.rb +0 -62
  34. data/lib/aia/main.rb +0 -265
  35. data/lib/aia/prompt.rb +0 -275
  36. data/lib/aia/tools/backend_common.rb +0 -58
  37. data/lib/aia/tools/client.rb +0 -197
  38. data/lib/aia/tools/editor.rb +0 -52
  39. data/lib/aia/tools/glow.rb +0 -90
  40. data/lib/aia/tools/llm.rb +0 -77
  41. data/lib/aia/tools/mods.rb +0 -100
  42. data/lib/aia/tools/sgpt.rb +0 -79
  43. data/lib/aia/tools/subl.rb +0 -68
  44. data/lib/aia/tools/vim.rb +0 -93
  45. data/lib/aia/tools.rb +0 -88
  46. data/lib/aia/user_query.rb +0 -21
  47. data/lib/core_ext/string_wrap.rb +0 -73
  48. data/lib/core_ext/tty-spinner_log.rb +0 -25
  49. data/man/aia.1 +0 -272
  50. data/man/aia.1.md +0 -236
@@ -0,0 +1,215 @@
1
+ # lib/aia/session.rb
2
+
3
+ require 'tty-spinner'
4
+ require 'tty-screen'
5
+ require 'reline'
6
+ require 'prompt_manager'
7
+ require 'json'
8
+ require 'fileutils'
9
+ require 'amazing_print'
10
+ require_relative 'directive_processor'
11
+ require_relative 'history_manager'
12
+ require_relative 'context_manager'
13
+ require_relative 'ui_presenter'
14
+ require_relative 'chat_processor_service'
15
+ require_relative 'prompt_handler'
16
+ require_relative 'utility'
17
+
18
+ module AIA
19
+ class Session
20
+ KW_HISTORY_MAX = 5 # Maximum number of history entries per keyword
21
+ TERSE_PROMPT = "\nKeep your response short and to the point.\n"
22
+
23
+ def initialize(prompt_handler)
24
+ @prompt_handler = prompt_handler
25
+
26
+ if AIA.chat? && AIA.config.prompt_id.empty?
27
+ prompt_instance = nil
28
+ @history_manager = nil
29
+ else
30
+ prompt_instance = @prompt_handler.get_prompt(AIA.config.prompt_id)
31
+ @history_manager = HistoryManager.new(prompt: prompt_instance)
32
+ end
33
+
34
+ @context_manager = ContextManager.new(system_prompt: AIA.config.system_prompt) # Add this line
35
+ @ui_presenter = UIPresenter.new
36
+ @directive_processor = DirectiveProcessor.new
37
+ @chat_processor = ChatProcessorService.new(@ui_presenter, @directive_processor)
38
+
39
+ if AIA.config.out_file && !AIA.append? && File.exist?(AIA.config.out_file)
40
+ File.open(AIA.config.out_file, 'w') {} # Truncate the file
41
+ end
42
+ end
43
+
44
+ # Starts the session, processing the initial prompt and handling user
45
+ # interactions. It manages the flow of prompts, context, and responses.
46
+ def start
47
+ prompt_id = AIA.config.prompt_id
48
+ role_id = AIA.config.role
49
+
50
+ # Handle chat mode *only* if NO initial prompt is given
51
+ if AIA.chat?
52
+ AIA::Utility.robot
53
+ if prompt_id.empty? && role_id.empty?
54
+ start_chat
55
+ return
56
+ end
57
+ end
58
+
59
+
60
+ # --- Get and process the initial prompt ---
61
+ begin
62
+ prompt = @prompt_handler.get_prompt(prompt_id, role_id)
63
+ rescue StandardError => e
64
+ puts "Error: #{e.message}"
65
+ return
66
+ end
67
+
68
+ # Collect variable values if needed
69
+ variables = prompt.parameters.keys
70
+
71
+ if variables && !variables.empty?
72
+ variable_values = {}
73
+ history_manager = AIA::HistoryManager.new prompt: prompt
74
+
75
+ variables.each do |var_name|
76
+ # History is based on the prompt ID and the variable name (without brackets)
77
+ history = prompt.parameters[var_name]
78
+
79
+ # Ask the user for the variable
80
+ value = history_manager.request_variable_value(
81
+ variable_name: var_name,
82
+ history_values: history
83
+ )
84
+ # Store the value using the original BRACKETED key from prompt.parameters
85
+ if history.include? value
86
+ history.delete(value)
87
+ end
88
+ history << value
89
+ if history.size > HistoryManager::MAX_VARIABLE_HISTORY
90
+ history.shift
91
+ end
92
+ variable_values[var_name] = history
93
+ end
94
+
95
+ # Assign collected values back for prompt_manager substitution
96
+ prompt.parameters = variable_values
97
+ end
98
+
99
+ # Add terse instruction if needed
100
+ if AIA.terse?
101
+ prompt.text << TERSE_PROMPT
102
+ end
103
+
104
+ prompt.save
105
+
106
+ # Substitute variables and get final prompt text
107
+ prompt_text = prompt.to_s
108
+
109
+ # Add context files if any
110
+ if AIA.config.context_files && !AIA.config.context_files.empty?
111
+ context = AIA.config.context_files.map do |file|
112
+ File.read(file) rescue "Error reading file: #{file}"
113
+ end.join("\n\n")
114
+ prompt_text = "#{prompt_text}\n\nContext:\n#{context}"
115
+ end
116
+
117
+ # Determine operation type
118
+ operation_type = @chat_processor.determine_operation_type(AIA.config.model)
119
+
120
+ # Add initial user prompt to context *before* sending to AI
121
+ @context_manager.add_to_context(role: 'user', content: prompt_text)
122
+
123
+ # Process the initial prompt
124
+ @ui_presenter.display_thinking_animation
125
+ # Send the current context (which includes the user prompt)
126
+ response = @chat_processor.process_prompt(@context_manager.get_context, operation_type)
127
+
128
+ # Add AI response to context
129
+ @context_manager.add_to_context(role: 'assistant', content: response)
130
+
131
+ # Output the response
132
+ @chat_processor.output_response(response) # Handles display
133
+
134
+ # Process next prompts/pipeline (if any)
135
+ @chat_processor.process_next_prompts(response, @prompt_handler)
136
+
137
+ # --- Enter chat mode AFTER processing initial prompt ---
138
+ if AIA.chat?
139
+ @ui_presenter.display_separator # Add separator
140
+ start_chat # start_chat will use the now populated context
141
+ end
142
+ end
143
+
144
+ # Starts the interactive chat session.
145
+ def start_chat
146
+ # Consider if display_chat_header is needed if robot+separator already shown
147
+ # For now, let's keep it, maybe add an indicator message
148
+ puts "\nEntering interactive chat mode..."
149
+ @ui_presenter.display_chat_header
150
+
151
+ Reline::HISTORY.clear # Keep Reline history for user input editing, separate from chat context
152
+
153
+ loop do
154
+ # Get user input
155
+ prompt = @ui_presenter.ask_question
156
+
157
+
158
+ break if prompt.nil? || prompt.strip.downcase == 'exit' || prompt.strip.empty?
159
+
160
+ if AIA.config.out_file
161
+ File.open(AIA.config.out_file, 'a') do |file|
162
+ file.puts "\nYou: #{prompt}"
163
+ end
164
+ end
165
+
166
+ if @directive_processor.directive?(prompt)
167
+ directive_output = @directive_processor.process(prompt, @context_manager) # Pass context_manager
168
+
169
+ # Add check for specific directives like //clear that might modify context
170
+ if prompt.strip.start_with?('//clear', '#!clear:')
171
+ # Context is likely cleared within directive_processor.process now
172
+ # or add @context_manager.clear_context here if not handled internally
173
+ @ui_presenter.display_info("Chat context cleared.")
174
+ next # Skip API call after clearing
175
+ elsif directive_output.nil? || directive_output.strip.empty?
176
+ next # Skip API call if directive produced no output and wasn't //clear
177
+ else
178
+ puts "\n#{directive_output}\n"
179
+ # Optionally add directive output to context or handle as needed
180
+ # Example: Add a summary to context
181
+ # @context_manager.add_to_context(role: 'assistant', content: "Directive executed. Output:\n#{directive_output}")
182
+ # For now, just use a placeholder prompt modification:
183
+ prompt = "I executed this directive: #{prompt}\nHere's the output: #{directive_output}\nLet's continue our conversation."
184
+ # Fall through to add this modified prompt to context and send to AI
185
+ end
186
+ end
187
+
188
+ # Use ContextManager instead of HistoryManager
189
+ @context_manager.add_to_context(role: 'user', content: prompt)
190
+
191
+ # Use ContextManager to get the conversation
192
+ conversation = @context_manager.get_context # System prompt handled internally
193
+
194
+ # FIXME: remove this comment once verified
195
+ # is conversation the same thing as the context for a chat session? YES
196
+ # if so need to somehow delete it when the //clear directive is entered. - Addressed above/in DirectiveProcessor
197
+
198
+ operation_type = @chat_processor.determine_operation_type(AIA.config.model)
199
+ @ui_presenter.display_thinking_animation
200
+ response = @chat_processor.process_prompt(conversation, operation_type)
201
+
202
+ @ui_presenter.display_ai_response(response)
203
+
204
+ # Use ContextManager instead of HistoryManager
205
+ @context_manager.add_to_context(role: 'assistant', content: response)
206
+
207
+ @chat_processor.speak(response)
208
+
209
+ @ui_presenter.display_separator
210
+ end
211
+
212
+ @ui_presenter.display_chat_end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,109 @@
1
+ # lib/aia/shell_command_executor.rb
2
+
3
+ module AIA
4
+ class ShellCommandExecutor
5
+ DANGEROUS_PATTERNS = [
6
+ # File system destructive commands
7
+ /\brm\s+(-[a-z]*)?f/i, # rm with force flag
8
+ /\bmkfs/i, # format filesystems
9
+ /\bdd\b.*\bof=/i, # dd with output file
10
+ /\bshred\b/i, # securely delete files
11
+ # System modification commands
12
+ /\bsystemctl\s+(stop|disable|mask)/i, # stopping system services
13
+ /\bchmod\s+777\b/i, # setting dangerous permissions
14
+ /\b(halt|poweroff|shutdown|reboot)\b/i, # system power commands
15
+ # Network security related
16
+ /\btcpdump\b/i, # packet capturing
17
+ /\bifconfig\b.*\bdown\b/i, # taking down network interfaces
18
+ # Process control
19
+ /\bkill\s+-9\b/i, # force killing processes
20
+ /\bpkill\b/i # pattern-based process killing
21
+ ].freeze
22
+
23
+
24
+ MAX_COMMAND_LENGTH = 500
25
+
26
+
27
+
28
+ def initialize
29
+ # Stub method for future implementation
30
+ end
31
+
32
+ # Class-level
33
+
34
+
35
+
36
+
37
+
38
+ def self.execute_command(command)
39
+ new.execute_command(command)
40
+ end
41
+
42
+
43
+
44
+ def execute_command(command)
45
+ return "No command specified" if blank?(command)
46
+
47
+ validation_result = validate_command(command)
48
+ return validation_result if validation_result
49
+
50
+ `#{command}`.chomp
51
+ rescue StandardError => error
52
+ "Error executing shell command: #{error.message}"
53
+ end
54
+
55
+
56
+
57
+ def dangerous_command?(command)
58
+ return false if blank?(command)
59
+ DANGEROUS_PATTERNS.any? { |pattern| command =~ pattern }
60
+ end
61
+
62
+ private
63
+
64
+
65
+
66
+ def blank?(str)
67
+ str.nil? || str.strip.empty?
68
+ end
69
+
70
+
71
+
72
+ def validate_command(command)
73
+ command_length = command.length
74
+
75
+ if command_length > MAX_COMMAND_LENGTH
76
+ return "Error: Command too long (#{command_length} chars). Maximum length is #{MAX_COMMAND_LENGTH}."
77
+ end
78
+
79
+
80
+ is_dangerous = dangerous_command?(command)
81
+
82
+
83
+ if AIA.strict_shell_safety? && is_dangerous
84
+ return "Error: Potentially dangerous command blocked for security reasons: '#{command}'"
85
+ end
86
+
87
+ if AIA.shell_confirm? && is_dangerous
88
+ return prompt_confirmation(command)
89
+ end
90
+
91
+ nil # Command is valid
92
+ end
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+ def prompt_confirmation(command)
101
+ puts "\n⚠️ WARNING: Potentially dangerous shell command detected:"
102
+ puts "\n #{command}\n"
103
+ print "\nDo you want to execute this command? [y/N]: "
104
+ confirm = STDIN.gets.chomp.downcase
105
+ return "Command execution canceled by user" unless confirm == 'y'
106
+ nil
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,110 @@
1
+ # lib/aia/ui_presenter.rb
2
+
3
+ require 'tty-screen'
4
+ require 'reline'
5
+
6
+ module AIA
7
+ class UIPresenter
8
+ USER_PROMPT = "Follow up (cntl-D or 'exit' to end) #=> "
9
+
10
+
11
+ def initialize
12
+ @terminal_width = TTY::Screen.width
13
+ end
14
+
15
+ def display_chat_header
16
+ puts "#{'═' * @terminal_width}\n"
17
+ end
18
+
19
+
20
+ def display_thinking_animation
21
+ puts "\n⏳ Processing...\n"
22
+ end
23
+
24
+
25
+ def display_ai_response(response)
26
+ puts "\nAI: "
27
+ format_chat_response(response)
28
+
29
+ if AIA.config.out_file
30
+ File.open(AIA.config.out_file, 'a') do |file|
31
+ file.puts "\nAI: "
32
+ format_chat_response(response, file)
33
+ end
34
+ end
35
+ end
36
+
37
+
38
+ def format_chat_response(response, output = $stdout)
39
+ indent = ' '
40
+
41
+ in_code_block = false
42
+ language = ''
43
+
44
+ response.each_line do |line|
45
+ line = line.chomp
46
+
47
+ # Check for code block delimiters
48
+ if line.match?(/^```(\w*)$/) && !in_code_block
49
+ in_code_block = true
50
+ language = $1
51
+ output.puts "#{indent}```#{language}"
52
+ elsif line.match?(/^```$/) && in_code_block
53
+ in_code_block = false
54
+ output.puts "#{indent}```"
55
+ elsif in_code_block
56
+ # Print code with special formatting
57
+ output.puts "#{indent}#{line}"
58
+ else
59
+ # Handle regular text
60
+ output.puts "#{indent}#{line}"
61
+ end
62
+ end
63
+ end
64
+
65
+
66
+ def display_separator
67
+ puts "\n#{'─' * @terminal_width}"
68
+ end
69
+
70
+
71
+ def display_chat_end
72
+ puts "\nChat session ended."
73
+ end
74
+
75
+
76
+ # This is the follow up question in a chat session
77
+ def ask_question
78
+ puts USER_PROMPT
79
+ $stdout.flush # Ensure the prompt is displayed immediately
80
+ begin
81
+ input = Reline.readline('', true)
82
+ return nil if input.nil? # Handle Ctrl+D
83
+ Reline::HISTORY << input unless input.strip.empty?
84
+ input
85
+ rescue Interrupt
86
+ puts "\nChat session interrupted."
87
+ return 'exit'
88
+ end
89
+ end
90
+
91
+ def display_info(message)
92
+ puts "\n#{message}"
93
+ end
94
+
95
+ def with_spinner(message = "Processing", operation_type = nil)
96
+ if AIA.verbose?
97
+ spinner_message = operation_type ? "#{message} #{operation_type}..." : "#{message}..."
98
+ spinner = TTY::Spinner.new("[:spinner] #{spinner_message}", format: :bouncing_ball)
99
+ spinner.auto_spin
100
+
101
+ result = yield
102
+
103
+ spinner.stop
104
+ result
105
+ else
106
+ yield
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,24 @@
1
+ # lib/aia/utility.rb
2
+
3
+ module AIA
4
+ class Utility
5
+ class << self
6
+ # Displays the AIA robot ASCII art
7
+ def robot
8
+ puts <<-ROBOT
9
+
10
+ , ,
11
+ (\\____/) AI Assistant
12
+ (_oo_) #{AIA.config.model}
13
+ (O) is Online
14
+ __||__ \\)
15
+ [/______\\] /
16
+ / \\__AI__/ \\/
17
+ / /__\\
18
+ (\\ /____\\
19
+
20
+ ROBOT
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/aia/version.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # lib/aia/version.rb
2
- # frozen_string_literal: true
3
-
4
- require 'versionaire'
2
+ #
3
+ # This file defines the version of the AIA application.
4
+ # The VERSION constant defines the current version of the AIA application,
5
+ # which is read from the .version file in the project root.
5
6
 
7
+ # The AIA module serves as the namespace for the AIA application, which
8
+ # provides an interface for interacting with AI models and managing prompts.
6
9
  module AIA
7
- VERSION_FILEPATH = "#{__dir__}/../../.version"
8
- VERSION = Versionaire::Version File.read(VERSION_FILEPATH).strip
9
- def self.version = VERSION
10
+ # The VERSION constant defines the current version of the AIA application,
11
+ # which is read from the .version file in the project root.
12
+ VERSION = File.read(File.join(File.dirname(__FILE__), '..', '..', '.version')).strip
10
13
  end
data/lib/aia.rb CHANGED
@@ -1,78 +1,74 @@
1
1
  # lib/aia.rb
2
+ #
3
+ # This is the main entry point for the AIA application.
4
+ # The AIA module serves as the namespace for the AIA application, which
5
+ # provides an interface for interacting with AI models and managing prompts.
2
6
 
3
- def tramp_require(what, &block)
4
- loaded, require_result = false, nil
5
-
6
- begin
7
- require_result = require what
8
- loaded = true
9
- rescue Exception => ex
10
- # Do nothing
7
+ require 'ai_client'
8
+ require 'prompt_manager'
9
+ require 'debug_me'
10
+ include DebugMe
11
+ $DEBUG_ME = false
12
+ DebugMeDefaultOptions[:skip1] = true
13
+
14
+ require_relative 'extensions/openstruct_merge'
15
+ require_relative 'aia/utility'
16
+ require_relative 'aia/version'
17
+ require_relative 'aia/config'
18
+ require_relative 'aia/shell_command_executor'
19
+ require_relative 'aia/prompt_handler'
20
+ require_relative 'aia/ai_client_adapter'
21
+ require_relative 'aia/directive_processor'
22
+ require_relative 'aia/history_manager'
23
+ require_relative 'aia/ui_presenter'
24
+ require_relative 'aia/chat_processor_service'
25
+ require_relative 'aia/session'
26
+
27
+ # The AIA module serves as the namespace for the AIA application, which
28
+ # provides an interface for interacting with AI models and managing prompts.
29
+ module AIA
30
+ at_exit do
31
+ STDERR.puts "Exiting AIA application..."
11
32
  end
12
33
 
13
- yield if loaded and block_given?
14
-
15
- require_result
16
- end
17
-
18
- tramp_require('debug_me') {
19
- include DebugMe
20
- }
21
-
22
- require 'hashie'
23
- require 'openai'
24
- require 'os'
25
- require 'pathname'
26
- require 'reline'
27
- require 'shellwords'
28
- require 'tempfile'
29
-
30
- require 'tty-spinner'
31
-
32
- unless TTY::Spinner.new.respond_to?(:log)
33
- # Allows messages to be sent to the console while
34
- # the spinner is still spinning.
35
- require_relative './core_ext/tty-spinner_log'
36
- end
37
-
38
- require 'prompt_manager'
39
- require 'prompt_manager/storage/file_system_adapter'
34
+ @config = nil
40
35
 
41
- require_relative "aia/version"
42
- require_relative "aia/clause"
43
- require_relative "aia/main"
44
- require_relative "core_ext/string_wrap"
36
+ def self.config
37
+ @config
38
+ end
45
39
 
46
- module AIA
47
- class << self
48
- attr_accessor :config
49
- attr_accessor :client
40
+ def self.client
41
+ @config.client
42
+ end
50
43
 
51
- def run(args=ARGV)
52
- args = args.split(' ') if args.is_a?(String)
44
+ def self.client=(client)
45
+ @config.client = client
46
+ end
53
47
 
54
- # TODO: Currently this is a one and done architecture.
55
- # If the args contain an "-i" or and "--interactive"
56
- # flag could this turn into some kind of
57
- # conversation REPL?
58
-
59
- AIA::Main.new(args).call
48
+ def self.build_flags
49
+ @config.each_pair do |key, value|
50
+ if [TrueClass, FalseClass].include?(value.class)
51
+ define_singleton_method("#{key}?") do
52
+ @config[key]
53
+ end
54
+ end
60
55
  end
56
+ end
61
57
 
58
+ def self.run
59
+ @config = Config.setup
62
60
 
63
- def speak(what)
64
- return unless config.speak?
61
+ build_flags
65
62
 
66
- if OS.osx? && 'siri' == config.voice.downcase
67
- system "say #{Shellwords.escape(what)}"
68
- else
69
- Client.speak(what)
70
- end
63
+ # Load Fzf if fuzzy search is enabled and fzf is installed
64
+ if @config.fuzzy && system('which fzf >/dev/null 2>&1')
65
+ require_relative 'aia/fzf'
71
66
  end
72
67
 
68
+ prompt_handler = PromptHandler.new
69
+ @config.client = AIClientAdapter.new
70
+ session = Session.new(prompt_handler)
73
71
 
74
- def verbose? = AIA.config.verbose?
75
- def debug? = AIA.config.debug?
72
+ session.start
76
73
  end
77
74
  end
78
-
@@ -0,0 +1,44 @@
1
+ # lib/extensions/openstruct_merge.rb
2
+ #
3
+
4
+ require 'ostruct'
5
+
6
+ class OpenStruct
7
+ def self.merge(*args)
8
+ result = OpenStruct.new
9
+
10
+ args.each do |arg|
11
+ unless [Hash, OpenStruct].include?(arg.class)
12
+ raise ArgumentError, "Only OpenStruct or Hash objects are allowed. bad: #{arg.class}"
13
+ end
14
+
15
+ arg.each_pair do |key, value|
16
+ set_value(result, key, value)
17
+ end
18
+ end
19
+
20
+ result
21
+ end
22
+
23
+ # Sets value in result OpenStruct, handling nested OpenStruct and Hash objects
24
+ def self.set_value(result, key, value)
25
+ if value.is_a?(OpenStruct) || value.is_a?(Hash)
26
+ current_value = result[key]
27
+ current_value = {} if current_value.nil?
28
+ merged_value = merge(current_value, value.to_h)
29
+ result[key] = merged_value
30
+ else
31
+ result[key] = value
32
+ end
33
+ end
34
+ end
35
+
36
+ __END__
37
+
38
+ # Usage example
39
+ os1 = OpenStruct.new(a: 1, b: 2, e: OpenStruct.new(x: 9))
40
+ os2 = OpenStruct.new(b: 3, c: 4)
41
+ os3 = {d: 5, e: {y: 10}}
42
+
43
+ merged_os = OpenStruct.merge(os1, os2, os3)
44
+ puts merged_os.inspect