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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ # Manages conversation state globally, mapping user IDs to conversation UUIDs
5
+ # Handles all business logic: switching, finding current, labeling, etc.
6
+ class ConversationManager
7
+ # Valid label format: alphanumeric, dashes, and underscores
8
+ LABEL_REGEX = /\A[a-zA-Z0-9_-]+\z/
9
+
10
+ class << self
11
+ # Get or create the current conversation for a user
12
+ #
13
+ # @param user_id [String] User identifier
14
+ # @return [Conversation] Current conversation for the user
15
+ def current_for(user_id)
16
+ user_key = user_id.to_s
17
+ conversation = Conversation.where(user_id: user_key, is_current: true, archived: false).first
18
+
19
+ if conversation
20
+ Conversation[conversation.id]
21
+ else
22
+ create_new(user_key)
23
+ end
24
+ end
25
+
26
+ # Switch a user to a different conversation by label or UUID
27
+ # Auto-unarchives archived conversations when switching to them
28
+ #
29
+ # @param user_id [String] User identifier
30
+ # @param identifier [String] Conversation label or UUID to switch to
31
+ # @return [Conversation] The switched-to conversation
32
+ # @raise [Error] If conversation with given identifier doesn't exist
33
+ def switch(user_id, identifier)
34
+ user_key = user_id.to_s
35
+ identifier = identifier.to_s.strip
36
+
37
+ raise Error, "Usage: /switch <label-or-uuid>" if identifier.empty?
38
+
39
+ # First try to find by label (include archived)
40
+ conversation = Conversation.where(user_id: user_key, label: identifier).first
41
+
42
+ # If not found by label, treat as UUID (include archived)
43
+ conversation ||= Conversation.find(id: identifier, user_id: user_key)
44
+
45
+ raise Error, "Conversation '#{identifier}' not found" unless conversation
46
+
47
+ # Auto-unarchive if switching to an archived conversation
48
+ conversation.update(archived: false) if conversation.archived
49
+
50
+ # Clear current flag from all user's conversations
51
+ Conversation.where(user_id: user_key).update(is_current: false)
52
+
53
+ # Set new conversation as current
54
+ conversation.update(is_current: true)
55
+ Conversation[conversation.id]
56
+ end
57
+
58
+ # Create a new conversation and switch the user to it
59
+ #
60
+ # @param user_id [String] User identifier
61
+ # @return [Conversation] The newly created conversation
62
+ def create_new(user_id)
63
+ user_key = user_id.to_s
64
+
65
+ # Clear current flag from all user's conversations
66
+ Conversation.where(user_id: user_key).update(is_current: false)
67
+
68
+ # Create new conversation as current
69
+ conversation = Conversation.create(user_id: user_key, is_current: true)
70
+ Conversation[conversation.id]
71
+ end
72
+
73
+ # Get the UUID for a user's current conversation
74
+ #
75
+ # @param user_id [String] User identifier
76
+ # @return [String, nil] Current conversation UUID or nil if none exists
77
+ def current_uuid_for(user_id)
78
+ conversation = Conversation.where(user_id: user_id.to_s, is_current: true).first
79
+ conversation&.id
80
+ end
81
+
82
+ # List all conversation mappings (excluding archived by default)
83
+ #
84
+ # @param include_archived [Boolean] Whether to include archived conversations
85
+ # @return [Hash] Hash mapping UUIDs to {user_id, label} hashes
86
+ def all_mappings(include_archived: false)
87
+ dataset = include_archived ? Conversation.dataset : Conversation.where(archived: false)
88
+ dataset.all.map do |conv|
89
+ [conv.id, {"user_id" => conv.user_id, "label" => conv.label}]
90
+ end.to_h
91
+ end
92
+
93
+ # Remove a user's current conversation
94
+ #
95
+ # @param user_id [String] User identifier
96
+ def remove(user_id)
97
+ conversation = Conversation.where(user_id: user_id.to_s, is_current: true).first
98
+ return unless conversation
99
+
100
+ conversation.destroy
101
+ end
102
+
103
+ # Clear all conversations (use with caution)
104
+ def clear_all
105
+ Conversation.db[:conversations].delete
106
+ end
107
+
108
+ # Get the label for a conversation
109
+ #
110
+ # @param uuid [String] Conversation UUID
111
+ # @return [String, nil] Label value or nil
112
+ def label(uuid)
113
+ conversation = Conversation.find(id: uuid)
114
+ conversation&.label
115
+ end
116
+
117
+ # Set the label for a conversation
118
+ #
119
+ # @param uuid [String] Conversation UUID
120
+ # @param value [String] Label value
121
+ # @return [String] The label value
122
+ # @raise [Error] If label format is invalid or already in use
123
+ def set_label(uuid, value)
124
+ conversation = Conversation.find(id: uuid)
125
+ raise Error, "Conversation not found" unless conversation
126
+
127
+ # Validate label format
128
+ unless value.nil? || value.to_s.empty? || value.to_s.match?(LABEL_REGEX)
129
+ raise Error, "Invalid label format. Use only letters, numbers, dashes, and underscores."
130
+ end
131
+
132
+ # Check uniqueness per user (excluding current conversation)
133
+ user_id = conversation.user_id
134
+ if value && !value.to_s.empty? && label_exists?(user_id, value, exclude_uuid: uuid)
135
+ raise Error, "Label '#{value}' already in use by another conversation"
136
+ end
137
+
138
+ # Allow empty string to be treated as nil (clearing the label)
139
+ value = nil if value.to_s.empty?
140
+
141
+ Conversation.where(id: uuid).update(label: value)
142
+ value
143
+ end
144
+
145
+ # Check if a label exists for a user
146
+ #
147
+ # @param user_id [String] User identifier
148
+ # @param label [String] Label to check
149
+ # @param exclude_uuid [String, nil] UUID to exclude from check
150
+ # @return [Boolean] True if label exists for user
151
+ def label_exists?(user_id, label, exclude_uuid: nil)
152
+ return false unless label
153
+
154
+ query = Conversation.where(user_id: user_id.to_s, label: label)
155
+ query = query.exclude(id: exclude_uuid) if exclude_uuid
156
+ query.count > 0
157
+ end
158
+
159
+ # List all conversations for a user
160
+ # Sorted by updated_at in descending order (most recently updated first)
161
+ #
162
+ # @param user_id [String] User identifier
163
+ # @param archived [Boolean, nil] Filter by archived status (nil = all, true = archived only, false = unarchived only)
164
+ # @return [Array<Hash>] Array of {uuid, label, updated_at} hashes
165
+ def list_by_user(user_id, archived: false)
166
+ dataset = Conversation.where(user_id: user_id.to_s)
167
+ dataset = dataset.where(archived: archived) unless archived.nil?
168
+ dataset.order(Sequel.desc(:updated_at)).all.map do |conv|
169
+ {uuid: conv.id, label: conv.label, updated_at: conv.updated_at}
170
+ end
171
+ end
172
+
173
+ # Find conversation UUID by label for a user
174
+ #
175
+ # @param user_id [String] User identifier
176
+ # @param label [String] Label to search for
177
+ # @return [String, nil] UUID or nil if not found
178
+ def find_by_label(user_id, label)
179
+ conversation = Conversation.where(user_id: user_id.to_s, label: label).first
180
+ conversation&.id
181
+ end
182
+
183
+ # Archive a conversation by label or UUID, or archive current if no identifier given
184
+ # When archiving current conversation, automatically creates a new one
185
+ #
186
+ # @param user_id [String] User identifier
187
+ # @param identifier [String, nil] Conversation label or UUID to archive (nil = archive current)
188
+ # @return [Hash] Hash with :archived and :new_conversation keys
189
+ # @raise [Error] If conversation not found
190
+ def archive(user_id, identifier = nil)
191
+ user_key = user_id.to_s
192
+ identifier = identifier.to_s.strip
193
+
194
+ if identifier.empty?
195
+ # Archive current conversation
196
+ conversation = Conversation.where(user_id: user_key, is_current: true).first
197
+ raise Error, "No current conversation to archive" unless conversation
198
+
199
+ # Archive the current conversation
200
+ conversation.update(archived: true, is_current: false)
201
+
202
+ # Create a new conversation (becomes current)
203
+ new_conversation = create_new(user_key)
204
+
205
+ {
206
+ archived: Conversation[conversation.id],
207
+ new_conversation: new_conversation
208
+ }
209
+ else
210
+ # Archive by label or UUID
211
+ conversation = Conversation.where(user_id: user_key, label: identifier).first
212
+ conversation ||= Conversation.find(id: identifier, user_id: user_key)
213
+
214
+ raise Error, "Conversation '#{identifier}' not found" unless conversation
215
+
216
+ # Cannot archive current conversation (must use archive without args)
217
+ raise Error, "Cannot archive the current conversation. Use /archive without arguments to archive current and start new." if conversation.is_current
218
+
219
+ conversation.update(archived: true, is_current: false)
220
+ {archived: Conversation[conversation.id]}
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "fileutils"
5
+
6
+ module Botiasloop
7
+ # Database connection and schema management for SQLite
8
+ class Database
9
+ # Default database path
10
+ DEFAULT_PATH = File.expand_path("~/.config/botiasloop/db.sqlite")
11
+
12
+ class << self
13
+ # Get or create database connection
14
+ # Automatically sets up schema on first connection
15
+ #
16
+ # @return [Sequel::SQLite::Database]
17
+ def connect
18
+ @db ||= begin
19
+ db = Sequel.sqlite(DEFAULT_PATH)
20
+ setup_schema!(db)
21
+ db
22
+ end
23
+ end
24
+
25
+ # Set up database schema
26
+ # Creates tables if they don't exist
27
+ def setup!
28
+ db = @db || connect
29
+ setup_schema!(db)
30
+ end
31
+
32
+ # Reset database - delete all data
33
+ def reset!
34
+ db = connect
35
+ db[:messages].delete if db.table_exists?(:messages)
36
+ db[:conversations].delete if db.table_exists?(:conversations)
37
+ end
38
+
39
+ # Close database connection
40
+ def disconnect
41
+ @db&.disconnect
42
+ @db = nil
43
+ end
44
+
45
+ private
46
+
47
+ # Set up database schema on a connection
48
+ # Creates tables if they don't exist
49
+ #
50
+ # @param db [Sequel::SQLite::Database] Database connection
51
+ def setup_schema!(db)
52
+ # Ensure directory exists
53
+ FileUtils.mkdir_p(File.dirname(DEFAULT_PATH))
54
+
55
+ # Create conversations table
56
+ db.create_table?(:conversations) do
57
+ String :id, primary_key: true
58
+ String :user_id, null: false
59
+ String :label
60
+ TrueClass :is_current, default: false
61
+ TrueClass :archived, default: false
62
+ Integer :input_tokens, default: 0
63
+ Integer :output_tokens, default: 0
64
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
65
+ DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
66
+
67
+ index [:user_id, :label], unique: true
68
+ index [:user_id, :archived]
69
+ end
70
+
71
+ # Create messages table
72
+ db.create_table?(:messages) do
73
+ primary_key :id
74
+ String :conversation_id, null: false
75
+ String :role, null: false
76
+ String :content, null: false, text: true
77
+ Integer :input_tokens, default: 0
78
+ Integer :output_tokens, default: 0
79
+ DateTime :timestamp, default: Sequel::CURRENT_TIMESTAMP
80
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
81
+
82
+ foreign_key [:conversation_id], :conversations, on_delete: :cascade
83
+ index [:conversation_id]
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Establish database connection when models are loaded
91
+ # This ensures Sequel models have a valid database connection
92
+ Botiasloop::Database.connect
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+
6
+ module Botiasloop
7
+ class Loop
8
+ MAX_TOOL_RETRIES = 3
9
+
10
+ # Initialize the ReAct loop
11
+ #
12
+ # @param provider [RubyLLM::Provider] Provider instance
13
+ # @param model [RubyLLM::Model] Model instance
14
+ # @param registry [Tools::Registry] Tool registry
15
+ # @param max_iterations [Integer] Maximum ReAct iterations
16
+ def initialize(provider, model, registry, max_iterations: 20)
17
+ @provider = provider
18
+ @model = model
19
+ @registry = registry
20
+ @max_iterations = max_iterations
21
+ @logger = Logger.new($stderr)
22
+ end
23
+
24
+ # Run the ReAct loop
25
+ #
26
+ # @param conversation [Conversation] Conversation instance
27
+ # @param user_input [String] User input
28
+ # @return [String] Final response
29
+ # @raise [Error] If max iterations exceeded
30
+ def run(conversation, user_input)
31
+ conversation.add("user", user_input)
32
+ messages = build_messages(conversation)
33
+
34
+ # Track accumulated tokens across all iterations
35
+ total_input_tokens = 0
36
+ total_output_tokens = 0
37
+
38
+ @max_iterations.times do
39
+ response = iterate(messages)
40
+
41
+ # Accumulate tokens from this response
42
+ total_input_tokens += response.input_tokens || 0
43
+ total_output_tokens += response.output_tokens || 0
44
+
45
+ if response.tool_call?
46
+ # Add the assistant's message with tool_calls first
47
+ messages << response
48
+
49
+ response.tool_calls.each_value do |tool_call|
50
+ observation = execute_tool(tool_call)
51
+ messages << build_tool_result_message(tool_call.id, observation)
52
+ end
53
+ else
54
+ conversation.add("assistant", response.content, input_tokens: total_input_tokens, output_tokens: total_output_tokens)
55
+ return response.content
56
+ end
57
+ end
58
+
59
+ raise MaxIterationsExceeded.new(@max_iterations)
60
+ end
61
+
62
+ private
63
+
64
+ def build_messages(conversation)
65
+ system_prompt = [RubyLLM::Message.new(
66
+ role: :system,
67
+ content: conversation.system_prompt
68
+ )]
69
+
70
+ system_prompt + conversation.history.map do |msg|
71
+ role = msg[:role] || msg["role"]
72
+ content = msg[:content] || msg["content"]
73
+ RubyLLM::Message.new(
74
+ role: role.to_sym,
75
+ content: content
76
+ )
77
+ end
78
+ end
79
+
80
+ def iterate(messages)
81
+ tool_schemas = @registry.schemas
82
+ @provider.complete(
83
+ messages,
84
+ tools: tool_schemas,
85
+ temperature: nil,
86
+ model: @model
87
+ )
88
+ end
89
+
90
+ def build_tool_result_message(tool_call_id, content)
91
+ RubyLLM::Message.new(
92
+ role: :tool,
93
+ content: content,
94
+ tool_call_id: tool_call_id
95
+ )
96
+ end
97
+
98
+ def execute_tool(tool_call)
99
+ @logger.info "[Tool] Executing #{tool_call.name} with arguments: #{tool_call.arguments}"
100
+ retries = 0
101
+ begin
102
+ result = @registry.execute(tool_call.name, tool_call.arguments)
103
+ build_observation(result)
104
+ rescue Error => e
105
+ retries += 1
106
+ retry if retries < MAX_TOOL_RETRIES
107
+ "Error: #{e.message}"
108
+ end
109
+ end
110
+
111
+ def build_observation(result)
112
+ result.to_s
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Skills
5
+ # Discovers and loads skills from gem and user directories
6
+ class Loader
7
+ # @return [Array<Skill>] All loaded skills (default + user)
8
+ def self.load_all_skills
9
+ load_default_skills + load_user_skills
10
+ end
11
+
12
+ # @return [Array<Skill>] Skills shipped with the gem
13
+ def self.load_default_skills
14
+ skills_dir = File.join(Botiasloop.root, "data", "skills")
15
+ load_from_directory(skills_dir)
16
+ end
17
+
18
+ # @return [Array<Skill>] Skills from user's ~/skills/ directory
19
+ def self.load_user_skills
20
+ user_skills_dir = File.expand_path("~/skills")
21
+ load_from_directory(user_skills_dir)
22
+ end
23
+
24
+ # Load skills from a specific directory
25
+ # @param dir [String] Directory path containing skill subdirectories
26
+ # @return [Array<Skill>]
27
+ def self.load_from_directory(dir)
28
+ return [] unless File.directory?(dir)
29
+
30
+ skills = []
31
+ Dir.entries(dir).each do |entry|
32
+ next if entry == "." || entry == ".."
33
+
34
+ skill_path = File.join(dir, entry)
35
+ next unless File.directory?(skill_path)
36
+
37
+ skill_md_path = File.join(skill_path, "SKILL.md")
38
+ next unless File.exist?(skill_md_path)
39
+
40
+ begin
41
+ skills << Skill.new(skill_path)
42
+ rescue Error => e
43
+ warn "Failed to load skill from #{skill_path}: #{e.message}"
44
+ end
45
+ end
46
+
47
+ skills
48
+ end
49
+
50
+ # Find a specific skill by name
51
+ # @param name [String] Skill name
52
+ # @return [Skill, nil]
53
+ def self.find_by_name(name)
54
+ load_all_skills.find { |skill| skill.name == name }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skill"
4
+ require_relative "loader"
5
+
6
+ module Botiasloop
7
+ module Skills
8
+ # Registry for managing available skills
9
+ class Registry
10
+ attr_reader :skills
11
+
12
+ def initialize
13
+ @skills = Loader.load_all_skills
14
+ end
15
+
16
+ # Get all skills as formatted table for system prompt
17
+ # @return [String]
18
+ def skills_table
19
+ return "No skills available." if @skills.empty?
20
+
21
+ header = "| Skill Name | Description | Path |"
22
+ separator = "|------------|-------------|------|"
23
+ rows = @skills.map { |skill| "| #{skill.name} | #{skill.description} | #{skill.path} |" }
24
+
25
+ [header, separator, *rows].join("\n")
26
+ end
27
+
28
+ # Find a skill by name
29
+ # @param name [String]
30
+ # @return [Skill, nil]
31
+ def find(name)
32
+ @skills.find { |skill| skill.name == name }
33
+ end
34
+
35
+ # Get all skill names
36
+ # @return [Array<String>]
37
+ def names
38
+ @skills.map(&:name)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Skills
5
+ # Represents a skill loaded from a SKILL.md file
6
+ # Follows the agentskills.io specification
7
+ class Skill
8
+ attr_reader :name, :description, :path, :metadata, :license, :compatibility, :skill_md_path, :body
9
+
10
+ # @param path [String] Path to skill directory containing SKILL.md
11
+ def initialize(path)
12
+ @path = File.expand_path(path)
13
+ @skill_md_path = File.join(@path, "SKILL.md")
14
+
15
+ raise Error, "Skill not found: #{@skill_md_path}" unless File.exist?(@skill_md_path)
16
+
17
+ parse_skill_md
18
+ end
19
+
20
+ # @return [String] Full content of SKILL.md
21
+ def content
22
+ @content ||= File.read(@skill_md_path)
23
+ end
24
+
25
+ private
26
+
27
+ def parse_skill_md
28
+ file_content = content
29
+
30
+ if file_content =~ /^---\s*$
31
+ ?(.*?)\n^---\s*$
32
+ ?(.*)$/m
33
+ frontmatter = ::Regexp.last_match(1)
34
+ @body = ::Regexp.last_match(2).strip
35
+ else
36
+ raise Error, "Invalid SKILL.md format (missing frontmatter): #{@skill_md_path}"
37
+ end
38
+
39
+ metadata = parse_frontmatter(frontmatter)
40
+
41
+ @name = metadata["name"]
42
+ @description = metadata["description"]
43
+ @license = metadata["license"]
44
+ @compatibility = metadata["compatibility"]
45
+ @metadata = metadata["metadata"] || {}
46
+
47
+ validate!
48
+ end
49
+
50
+ def parse_frontmatter(frontmatter)
51
+ require "yaml"
52
+ YAML.safe_load(frontmatter, permitted_classes: [Date, Time])
53
+ rescue Psych::SyntaxError => e
54
+ raise Error, "Invalid YAML frontmatter in #{@skill_md_path}: #{e.message}"
55
+ end
56
+
57
+ def validate!
58
+ raise Error, "Missing 'name' in skill frontmatter: #{@skill_md_path}" if @name.nil? || @name.empty?
59
+ raise Error, "Missing 'description' in skill frontmatter: #{@skill_md_path}" if @description.nil? || @description.empty?
60
+
61
+ validate_name_format!
62
+ end
63
+
64
+ def validate_name_format!
65
+ # Max 64 characters, lowercase alphanumeric and hyphens only
66
+ # Must not start or end with hyphen, no consecutive hyphens
67
+ unless @name =~ /\A[a-z0-9]+(-[a-z0-9]+)*\z/ && @name.length <= 64
68
+ raise Error, "Invalid skill name '#{@name}' in #{@skill_md_path}. " \
69
+ "Must be 1-64 chars, lowercase alphanumeric and hyphens only, " \
70
+ "no leading/trailing/consecutive hyphens."
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end