botiasloop 0.0.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +343 -0
  3. data/bin/botiasloop +155 -0
  4. data/data/skills/skill-creator/SKILL.md +329 -0
  5. data/data/skills/skill-creator/assets/ruby_api_cli_template.rb +151 -0
  6. data/data/skills/skill-creator/references/specification.md +99 -0
  7. data/lib/botiasloop/agent.rb +112 -0
  8. data/lib/botiasloop/channels/base.rb +248 -0
  9. data/lib/botiasloop/channels/cli.rb +101 -0
  10. data/lib/botiasloop/channels/telegram.rb +348 -0
  11. data/lib/botiasloop/channels.rb +64 -0
  12. data/lib/botiasloop/channels_manager.rb +299 -0
  13. data/lib/botiasloop/commands/archive.rb +109 -0
  14. data/lib/botiasloop/commands/base.rb +54 -0
  15. data/lib/botiasloop/commands/compact.rb +78 -0
  16. data/lib/botiasloop/commands/context.rb +34 -0
  17. data/lib/botiasloop/commands/conversations.rb +40 -0
  18. data/lib/botiasloop/commands/help.rb +30 -0
  19. data/lib/botiasloop/commands/label.rb +64 -0
  20. data/lib/botiasloop/commands/new.rb +21 -0
  21. data/lib/botiasloop/commands/registry.rb +121 -0
  22. data/lib/botiasloop/commands/reset.rb +18 -0
  23. data/lib/botiasloop/commands/status.rb +32 -0
  24. data/lib/botiasloop/commands/switch.rb +76 -0
  25. data/lib/botiasloop/commands/system_prompt.rb +20 -0
  26. data/lib/botiasloop/commands.rb +22 -0
  27. data/lib/botiasloop/config.rb +58 -0
  28. data/lib/botiasloop/conversation.rb +189 -0
  29. data/lib/botiasloop/conversation_manager.rb +225 -0
  30. data/lib/botiasloop/database.rb +92 -0
  31. data/lib/botiasloop/loop.rb +115 -0
  32. data/lib/botiasloop/skills/loader.rb +58 -0
  33. data/lib/botiasloop/skills/registry.rb +42 -0
  34. data/lib/botiasloop/skills/skill.rb +75 -0
  35. data/lib/botiasloop/systemd_service.rb +300 -0
  36. data/lib/botiasloop/tool.rb +24 -0
  37. data/lib/botiasloop/tools/registry.rb +68 -0
  38. data/lib/botiasloop/tools/shell.rb +50 -0
  39. data/lib/botiasloop/tools/web_search.rb +64 -0
  40. data/lib/botiasloop/version.rb +5 -0
  41. data/lib/botiasloop.rb +45 -0
  42. metadata +250 -0
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Channels
5
+ # Registry for channel classes
6
+ #
7
+ # The Registry maintains a static mapping of channel identifiers to
8
+ # channel classes. It does not manage runtime instances - that is handled
9
+ # by ChannelsManager.
10
+ #
11
+ class Registry
12
+ attr_reader :channels
13
+
14
+ def initialize
15
+ @channels = {}
16
+ end
17
+
18
+ # Register a channel class
19
+ #
20
+ # @param channel_class [Class] Channel class inheriting from Base
21
+ def register(channel_class)
22
+ identifier = channel_class.channel_identifier
23
+ raise Error, "Channel class must define channel_name" unless identifier
24
+
25
+ @channels[identifier] = channel_class
26
+ end
27
+
28
+ # Deregister a channel by name
29
+ #
30
+ # @param name [Symbol] Channel identifier
31
+ def deregister(name)
32
+ @channels.delete(name)
33
+ end
34
+
35
+ # Get channel class by name
36
+ #
37
+ # @param name [Symbol] Channel identifier
38
+ # @return [Class, nil] Channel class or nil if not found
39
+ def [](name)
40
+ @channels[name]
41
+ end
42
+
43
+ # Get all registered channel names
44
+ #
45
+ # @return [Array<Symbol>] Channel identifiers
46
+ def names
47
+ @channels.keys
48
+ end
49
+
50
+ # Clear all registered channels
51
+ # Useful for testing to prevent state leakage
52
+ def clear
53
+ @channels.clear
54
+ end
55
+ end
56
+
57
+ # Singleton registry instance
58
+ #
59
+ # @return [Registry] The global channel registry
60
+ def self.registry
61
+ @registry ||= Registry.new
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Botiasloop
6
+ # Manages concurrent execution of multiple channels
7
+ #
8
+ # ChannelsManager provides a higher-level abstraction over the Channel Registry,
9
+ # handling the threading and lifecycle management required to run multiple
10
+ # channels simultaneously. Each channel runs in its own thread, allowing
11
+ # independent operation and error isolation.
12
+ #
13
+ # @example Basic usage
14
+ # config = Botiasloop::Config.load
15
+ # manager = Botiasloop::ChannelsManager.new(config)
16
+ # manager.start_channels.wait
17
+ #
18
+ class ChannelsManager
19
+ # Time to wait for graceful shutdown before force-killing threads
20
+ SHUTDOWN_TIMEOUT = 5
21
+
22
+ # Channels that should not be auto-started (interactive channels)
23
+ EXCLUDED_CHANNELS = %i[cli].freeze
24
+
25
+ attr_reader :config
26
+
27
+ # Initialize a new ChannelsManager
28
+ #
29
+ # @param config [Config] Configuration instance
30
+ def initialize(config)
31
+ @config = config
32
+ @logger = Logger.new($stdout)
33
+ @threads = {}
34
+ @instances = {}
35
+ @mutex = Mutex.new
36
+ @running = false
37
+ @shutdown_requested = false
38
+ end
39
+
40
+ # Start all configured channels in separate threads
41
+ #
42
+ # Each channel is spawned in its own thread, allowing concurrent
43
+ # operation. Channels with missing configuration are skipped with
44
+ # a warning. Startup failures are logged but don't prevent other
45
+ # channels from starting.
46
+ #
47
+ # @return [ChannelsManager] self for method chaining
48
+ # @raise [Error] If channels are already running
49
+ def start_channels
50
+ @mutex.synchronize do
51
+ raise Error, "Channels are already running" if @running
52
+
53
+ @running = true
54
+ @shutdown_requested = false
55
+ end
56
+
57
+ setup_signal_handlers
58
+
59
+ registry = Channels.registry
60
+ registry.channels.each do |identifier, channel_class|
61
+ next if EXCLUDED_CHANNELS.include?(identifier)
62
+
63
+ begin
64
+ instance = channel_class.new(@config)
65
+ rescue Error => e
66
+ if e.message.match?(/Missing required configuration/)
67
+ @logger.warn "[ChannelsManager] Skipping #{identifier}: #{e.message}"
68
+ next
69
+ end
70
+ raise
71
+ end
72
+
73
+ thread = spawn_channel_thread(identifier, instance)
74
+ @mutex.synchronize do
75
+ @threads[identifier] = thread
76
+ @instances[identifier] = instance
77
+ end
78
+
79
+ @logger.info "[ChannelsManager] Started #{identifier} in thread #{thread.object_id}"
80
+ end
81
+
82
+ # Monitor threads for crashes
83
+ spawn_monitor_thread unless @threads.empty?
84
+
85
+ self
86
+ end
87
+
88
+ # Stop all running channels gracefully
89
+ #
90
+ # Sends stop signal to all channel instances and waits for threads
91
+ # to complete. Force-kills threads that don't stop within timeout.
92
+ #
93
+ # @return [void]
94
+ def stop_all
95
+ @mutex.synchronize do
96
+ return unless @running
97
+
98
+ @shutdown_requested = true
99
+ @running = false
100
+ end
101
+
102
+ @logger.info "[ChannelsManager] Stopping all channels..."
103
+
104
+ # Stop all channel instances
105
+ @instances.each do |identifier, instance|
106
+ instance.stop if instance.running?
107
+ rescue => e
108
+ @logger.error "[ChannelsManager] Error stopping #{identifier}: #{e.message}"
109
+ end
110
+
111
+ # Wait for threads to complete
112
+ @threads.each do |identifier, thread|
113
+ unless thread.join(SHUTDOWN_TIMEOUT)
114
+ @logger.warn "[ChannelsManager] Force-killing #{identifier} thread"
115
+ thread.kill
116
+ end
117
+ end
118
+
119
+ @mutex.synchronize do
120
+ @threads.clear
121
+ @instances.clear
122
+ end
123
+
124
+ @logger.info "[ChannelsManager] All channels stopped"
125
+ end
126
+
127
+ # Check if any channels are currently running
128
+ #
129
+ # @return [Boolean] True if any channel thread is alive
130
+ def running?
131
+ @mutex.synchronize do
132
+ return false unless @running
133
+
134
+ @threads.any? { |_, thread| thread.alive? }
135
+ end
136
+ end
137
+
138
+ # Get the number of active channel threads
139
+ #
140
+ # @return [Integer] Count of alive threads
141
+ def thread_count
142
+ @mutex.synchronize do
143
+ @threads.count { |_, thread| thread.alive? }
144
+ end
145
+ end
146
+
147
+ # Get a specific channel instance
148
+ #
149
+ # @param identifier [Symbol] Channel identifier
150
+ # @return [Base, nil] Channel instance or nil if not running
151
+ def instance(identifier)
152
+ @mutex.synchronize do
153
+ @instances[identifier]
154
+ end
155
+ end
156
+
157
+ # Get status information for a specific channel
158
+ #
159
+ # @param identifier [Symbol] Channel identifier
160
+ # @return [Hash, nil] Status hash or nil if channel not found
161
+ # * :identifier [Symbol] Channel identifier
162
+ # * :running [Boolean] Whether channel instance reports running
163
+ # * :thread_alive [Boolean] Whether thread is alive
164
+ # * :thread_id [Integer] Thread object ID
165
+ def channel_status(identifier)
166
+ @mutex.synchronize do
167
+ instance = @instances[identifier]
168
+ thread = @threads[identifier]
169
+
170
+ return nil unless instance
171
+
172
+ {
173
+ identifier: identifier,
174
+ running: instance.running?,
175
+ thread_alive: thread&.alive? || false,
176
+ thread_id: thread&.object_id
177
+ }
178
+ end
179
+ end
180
+
181
+ # Get status for all running channels
182
+ #
183
+ # @return [Hash{Symbol => Hash}] Map of channel identifier to status
184
+ def all_statuses
185
+ @mutex.synchronize do
186
+ @instances.transform_values do |instance|
187
+ thread = @threads[instance.class.channel_identifier]
188
+ {
189
+ identifier: instance.class.channel_identifier,
190
+ running: instance.running?,
191
+ thread_alive: thread&.alive? || false,
192
+ thread_id: thread&.object_id
193
+ }
194
+ end
195
+ end
196
+ end
197
+
198
+ # Block until all channels have stopped
199
+ #
200
+ # Useful for daemon mode where the main thread should wait.
201
+ # Returns immediately if no channels are running.
202
+ #
203
+ # @return [void]
204
+ def wait
205
+ return unless running?
206
+
207
+ # Wait for all threads to complete
208
+ loop do
209
+ sleep 0.1
210
+ break unless running?
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ # Spawn a thread to run a channel
217
+ #
218
+ # @param identifier [Symbol] Channel identifier
219
+ # @param instance [Base] Channel instance
220
+ # @return [Thread] The spawned thread
221
+ def spawn_channel_thread(identifier, instance)
222
+ Thread.new do
223
+ Thread.current.name = "botiasloop-#{identifier}"
224
+
225
+ begin
226
+ instance.start
227
+ rescue => e
228
+ @logger.error "[ChannelsManager] Channel #{identifier} crashed: #{e.message}"
229
+ @logger.error "[ChannelsManager] #{e.backtrace&.first(5)&.join("\n")}"
230
+ end
231
+ end
232
+ end
233
+
234
+ # Spawn a monitor thread to detect channel crashes
235
+ #
236
+ # @return [Thread] The monitor thread
237
+ def spawn_monitor_thread
238
+ Thread.new do
239
+ Thread.current.name = "botiasloop-monitor"
240
+
241
+ loop do
242
+ sleep 1.0
243
+
244
+ @mutex.synchronize do
245
+ break if @shutdown_requested
246
+
247
+ @threads.each do |identifier, thread|
248
+ next if thread.alive?
249
+
250
+ instance = @instances[identifier]
251
+ if instance&.running?
252
+ @logger.error "[ChannelsManager] Thread for #{identifier} died unexpectedly"
253
+ @instances.delete(identifier)
254
+ @threads.delete(identifier)
255
+ end
256
+ end
257
+
258
+ # Exit monitor if no more threads
259
+ break if @threads.empty?
260
+ end
261
+ end
262
+ end
263
+ end
264
+
265
+ # Set up signal handlers for graceful shutdown
266
+ #
267
+ # @return [void]
268
+ def setup_signal_handlers
269
+ # Store original handlers
270
+ @original_int_handler = Signal.trap("INT") { handle_shutdown_signal("INT") }
271
+ @original_term_handler = Signal.trap("TERM") { handle_shutdown_signal("TERM") }
272
+ end
273
+
274
+ # Handle shutdown signals
275
+ #
276
+ # Signals are handled in a separate thread to avoid
277
+ # limitations of trap context (no Mutex operations).
278
+ #
279
+ # @param signal [String] Signal name
280
+ # @return [void]
281
+ def handle_shutdown_signal(signal)
282
+ # Defer to separate thread to avoid trap context limitations
283
+ Thread.new do
284
+ @logger.info "[ChannelsManager] Received #{signal}, shutting down..."
285
+ stop_all
286
+
287
+ # Call original handler if it exists
288
+ case signal
289
+ when "INT"
290
+ @original_int_handler&.call if @original_int_handler.respond_to?(:call)
291
+ when "TERM"
292
+ @original_term_handler&.call if @original_term_handler.respond_to?(:call)
293
+ end
294
+
295
+ exit(0)
296
+ end.join
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Archive command - archives a conversation by label or UUID, or current conversation if no args
6
+ class Archive < Base
7
+ command :archive
8
+ description "Archive current conversation (no args) or a specific conversation by label/UUID"
9
+
10
+ # Execute the archive command
11
+ # Without args: archives current conversation and creates a new one
12
+ # With args: archives the specified conversation
13
+ #
14
+ # @param context [Context] Execution context
15
+ # @param args [String, nil] Label or UUID to archive (nil = archive current)
16
+ # @return [String] Command response with archived conversation details
17
+ def execute(context, args = nil)
18
+ identifier = args.to_s.strip
19
+ result = ConversationManager.archive(context.user_id, identifier.empty? ? nil : identifier)
20
+
21
+ if result[:new_conversation]
22
+ # Archived current and created new
23
+ context.conversation = result[:new_conversation]
24
+ format_archive_current_response(result[:archived], result[:new_conversation])
25
+ else
26
+ # Archived specific conversation
27
+ format_archive_response(result[:archived])
28
+ end
29
+ rescue Botiasloop::Error => e
30
+ "Error: #{e.message}"
31
+ end
32
+
33
+ private
34
+
35
+ def format_archive_response(conversation)
36
+ lines = ["**Conversation archived successfully**"]
37
+ lines << "- UUID: #{conversation.uuid}"
38
+
39
+ lines << if conversation.label?
40
+ "- Label: #{conversation.label}"
41
+ else
42
+ "- Label: (no label)"
43
+ end
44
+
45
+ count = conversation.message_count
46
+ lines << "- Messages: #{count}"
47
+
48
+ last = conversation.last_activity
49
+ lines << if last
50
+ "- Last activity: #{format_time_ago(last)}"
51
+ else
52
+ "- Last activity: no activity"
53
+ end
54
+
55
+ lines.join("\n")
56
+ end
57
+
58
+ def format_archive_current_response(archived, new_conversation)
59
+ lines = ["**Current conversation archived and new conversation started**"]
60
+ lines << ""
61
+ lines << "Archived:"
62
+ lines << "- UUID: #{archived.uuid}"
63
+
64
+ lines << if archived.label?
65
+ "- Label: #{archived.label}"
66
+ else
67
+ "- Label: (no label)"
68
+ end
69
+
70
+ count = archived.message_count
71
+ lines << "- Messages: #{count}"
72
+
73
+ last = archived.last_activity
74
+ lines << if last
75
+ "- Last activity: #{format_time_ago(last)}"
76
+ else
77
+ "- Last activity: no activity"
78
+ end
79
+
80
+ lines << ""
81
+ lines << "New conversation:"
82
+ lines << "- UUID: #{new_conversation.uuid}"
83
+ lines << "- Label: (no label)"
84
+
85
+ lines.join("\n")
86
+ end
87
+
88
+ def format_time_ago(timestamp)
89
+ time = Time.parse(timestamp)
90
+ now = Time.now.utc
91
+ diff = now - time
92
+
93
+ if diff < 60
94
+ "just now"
95
+ elsif diff < 3600
96
+ "#{Integer(diff / 60)} minutes ago"
97
+ elsif diff < 86_400
98
+ "#{Integer(diff / 3600)} hours ago"
99
+ elsif diff < 604_800
100
+ "#{Integer(diff / 86_400)} days ago"
101
+ else
102
+ time.strftime("%Y-%m-%d %H:%M UTC")
103
+ end
104
+ rescue ArgumentError
105
+ timestamp
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Base class for all slash commands
6
+ # Provides DSL for defining command metadata
7
+ class Base
8
+ class << self
9
+ # Get or set the command name
10
+ # Automatically registers the command when name is set
11
+ #
12
+ # @param name [Symbol, nil] Command name to set
13
+ # @return [Symbol, nil] The command name
14
+ def command(name = nil)
15
+ if name
16
+ @command_name = name
17
+ # Auto-register when command name is set
18
+ Botiasloop::Commands.registry.register(self)
19
+ end
20
+ @command_name
21
+ end
22
+
23
+ alias_method :command_name, :command
24
+
25
+ # Get or set the command description
26
+ #
27
+ # @param text [String, nil] Description text to set
28
+ # @return [String, nil] The command description
29
+ def description(text = nil)
30
+ if text
31
+ @description = text
32
+ end
33
+ @description
34
+ end
35
+
36
+ # Called when a subclass is defined
37
+ # No-op - registration happens when command() is called
38
+ def inherited(subclass)
39
+ super
40
+ end
41
+ end
42
+
43
+ # Execute the command
44
+ #
45
+ # @param context [Context] Execution context
46
+ # @param args [String, nil] Command arguments
47
+ # @return [String] Command response
48
+ # @raise [NotImplementedError] Subclass must implement
49
+ def execute(context, args = nil)
50
+ raise NotImplementedError, "Subclass must implement #execute"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Botiasloop
6
+ module Commands
7
+ # Compact command - compresses conversation by summarizing older messages
8
+ class Compact < Base
9
+ KEEP_RECENT = 5
10
+ MIN_MESSAGES = 10
11
+
12
+ command :compact
13
+ description "Compress conversation by summarizing older messages"
14
+
15
+ def execute(context, _args = nil)
16
+ conversation = context.conversation
17
+ config = context.config
18
+
19
+ messages = conversation.history
20
+
21
+ if messages.length < MIN_MESSAGES
22
+ return "Need at least #{MIN_MESSAGES} messages to compact. Current: #{messages.length}"
23
+ end
24
+
25
+ # Split messages: older ones to summarize, recent ones to keep
26
+ older_messages = messages[0...-KEEP_RECENT]
27
+ recent_messages = messages.last(KEEP_RECENT)
28
+
29
+ # Generate summary using LLM
30
+ summary = summarize_messages(older_messages, config)
31
+
32
+ # Replace conversation history
33
+ conversation.compact!(summary, recent_messages)
34
+
35
+ compacted_count = older_messages.length
36
+ summary_preview = (summary.length > 100) ? "#{summary[0..100]}..." : summary
37
+ "Conversation #{conversation.uuid} compacted.\n" \
38
+ "#{compacted_count} messages summarized, #{recent_messages.length} recent messages kept.\n" \
39
+ "Summary: #{summary_preview}"
40
+ end
41
+
42
+ private
43
+
44
+ def summarize_messages(messages, config)
45
+ chat = create_chat(config)
46
+
47
+ # Format messages for summarization
48
+ conversation_text = messages.map do |msg|
49
+ "#{msg[:role]}: #{msg[:content]}"
50
+ end.join("\n\n")
51
+
52
+ prompt = <<~PROMPT
53
+ Please summarize the following conversation, preserving key context, decisions, and facts. Be concise but comprehensive:
54
+
55
+ #{conversation_text}
56
+ PROMPT
57
+
58
+ chat.add_message(role: :user, content: prompt)
59
+ response = chat.complete
60
+
61
+ response.content
62
+ end
63
+
64
+ def create_chat(config)
65
+ summarize_config = config.commands["summarize"] || {}
66
+
67
+ if summarize_config["provider"] && summarize_config["model"]
68
+ # Use configured provider/model for summarization
69
+ RubyLLM.chat(model: summarize_config["model"])
70
+ else
71
+ # Fall back to default model
72
+ default_model = config.providers["openrouter"]["model"]
73
+ RubyLLM.chat(model: default_model)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Context object passed to command executions
6
+ # Provides access to conversation, config, channel, and user info
7
+ class Context
8
+ # @return [Conversation] The current conversation
9
+ attr_accessor :conversation
10
+
11
+ # @return [Config] The bot configuration
12
+ attr_reader :config
13
+
14
+ # @return [Channels::Base, nil] The channel instance (nil in CLI)
15
+ attr_reader :channel
16
+
17
+ # @return [String, nil] The user/source identifier
18
+ attr_reader :user_id
19
+
20
+ # Initialize context
21
+ #
22
+ # @param conversation [Conversation] The current conversation
23
+ # @param config [Config] The bot configuration
24
+ # @param channel [Channels::Base, nil] The channel instance (nil in CLI)
25
+ # @param user_id [String, nil] The user/source identifier
26
+ def initialize(conversation:, config:, channel: nil, user_id: nil)
27
+ @conversation = conversation
28
+ @config = config
29
+ @channel = channel
30
+ @user_id = user_id
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Conversations command - lists all conversations
6
+ class Conversations < Base
7
+ command :conversations
8
+ description "List all conversations (use '/conversations archived' to list archived)"
9
+
10
+ # Execute the conversations command
11
+ # Lists non-archived conversations by default, or archived conversations when specified
12
+ # Sorted by last updated (most recent first)
13
+ #
14
+ # @param context [Context] Execution context
15
+ # @param args [String, nil] Arguments - 'archived' to list archived conversations
16
+ # @return [String] Formatted list of conversations
17
+ def execute(context, args = nil)
18
+ show_archived = args.to_s.strip.downcase == "archived"
19
+ conversations = ConversationManager.list_by_user(context.user_id, archived: show_archived)
20
+ current_uuid = ConversationManager.current_uuid_for(context.user_id)
21
+
22
+ lines = show_archived ? ["**Archived Conversations**"] : ["**Conversations**"]
23
+
24
+ if conversations.empty?
25
+ lines << (show_archived ? "No archived conversations found." : "No conversations found.")
26
+ return lines.join("\n")
27
+ end
28
+
29
+ conversations.each do |conv|
30
+ prefix = (conv[:uuid] == current_uuid) ? "[current] " : ""
31
+ label = conv[:label]
32
+ suffix = label ? " (#{label})" : ""
33
+ lines << "#{prefix}#{conv[:uuid]}#{suffix}"
34
+ end
35
+
36
+ lines.join("\n")
37
+ end
38
+ end
39
+ end
40
+ end