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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Botiasloop
6
+ # Systemd user service management for auto-start on boot
7
+ #
8
+ # Manages installation, enablement, and control of botiasloop
9
+ # as a systemd user service. This allows botiasloop to start
10
+ # automatically on user login and run in the background.
11
+ #
12
+ # @example Install and enable the service
13
+ # service = Botiasloop::SystemdService.new(config)
14
+ # service.install
15
+ # service.enable
16
+ # service.start
17
+ #
18
+ # @example Check status
19
+ # service = Botiasloop::SystemdService.new(config)
20
+ # status = service.status
21
+ # puts "Running: #{status[:active]}"
22
+ #
23
+ class SystemdService
24
+ attr_reader :config
25
+
26
+ # Service name used by systemd
27
+ SERVICE_NAME = "botiasloop.service"
28
+
29
+ # Initialize a new SystemdService instance
30
+ #
31
+ # @param config [Config] Configuration instance
32
+ def initialize(config)
33
+ @config = config
34
+ end
35
+
36
+ # Check if systemd is available on the system
37
+ #
38
+ # @return [Boolean] True if systemctl is available
39
+ def systemd_available?
40
+ !`which systemctl 2>/dev/null`.strip.empty?
41
+ end
42
+
43
+ # Check if the service file is installed
44
+ #
45
+ # @return [Boolean] True if service file exists
46
+ def installed?
47
+ File.exist?(service_file_path)
48
+ end
49
+
50
+ # Check if the service is enabled to start on boot
51
+ #
52
+ # @return [Boolean] True if service is enabled
53
+ def enabled?
54
+ return false unless systemd_available?
55
+
56
+ systemctl_quiet("is-enabled", SERVICE_NAME)
57
+ end
58
+
59
+ # Check if the service is currently active/running
60
+ #
61
+ # @return [Boolean] True if service is active
62
+ def active?
63
+ return false unless systemd_available?
64
+
65
+ systemctl_quiet("is-active", SERVICE_NAME)
66
+ end
67
+
68
+ # Install the service file
69
+ #
70
+ # Creates the systemd user directory if needed and writes
71
+ # the service configuration file.
72
+ #
73
+ # @raise [SystemdError] If installation fails
74
+ # @return [Boolean] True on success
75
+ def install
76
+ FileUtils.mkdir_p(systemd_user_dir)
77
+ File.write(service_file_path, service_template)
78
+ systemctl("daemon-reload")
79
+ true
80
+ rescue => e
81
+ raise SystemdError, "Failed to install service: #{e.message}"
82
+ end
83
+
84
+ # Uninstall the service
85
+ #
86
+ # Stops the service if running, disables it, removes the
87
+ # service file, and reloads systemd.
88
+ #
89
+ # @return [Boolean] True if uninstalled, false if not installed
90
+ def uninstall
91
+ return false unless installed?
92
+
93
+ stop if active?
94
+ disable if enabled?
95
+ FileUtils.rm_f(service_file_path)
96
+ systemctl("daemon-reload")
97
+ true
98
+ end
99
+
100
+ # Enable the service to start on boot
101
+ #
102
+ # Enables linger to allow user services to start at boot time,
103
+ # then enables the service. Falls back gracefully if linger fails.
104
+ #
105
+ # @raise [SystemdError] If systemd unavailable or service not installed
106
+ # @return [Boolean] True on success
107
+ def enable
108
+ raise SystemdError, "systemd is not available on this system" unless systemd_available?
109
+ raise SystemdError, "Service is not installed" unless installed?
110
+
111
+ enable_linger
112
+ systemctl("enable", SERVICE_NAME)
113
+ true
114
+ end
115
+
116
+ # Disable the service from starting on boot
117
+ #
118
+ # Disables the service then disables linger if it was enabled.
119
+ #
120
+ # @raise [SystemdError] If systemd unavailable
121
+ # @return [Boolean] True on success
122
+ def disable
123
+ raise SystemdError, "systemd is not available on this system" unless systemd_available?
124
+
125
+ systemctl("disable", SERVICE_NAME)
126
+ disable_linger
127
+ true
128
+ end
129
+
130
+ # Check if linger is enabled for the current user
131
+ #
132
+ # Linger allows user services to start at boot time
133
+ # without requiring a user login session.
134
+ #
135
+ # @return [Boolean] True if linger is enabled
136
+ def linger_enabled?
137
+ output = `loginctl show-user $USER --property=Linger 2>/dev/null`.strip
138
+ output == "Linger=yes"
139
+ end
140
+
141
+ # Enable linger for the current user
142
+ #
143
+ # Allows user services to start at boot time.
144
+ # Does nothing if already enabled.
145
+ #
146
+ # @return [Boolean] True on success or already enabled, false on failure
147
+ def enable_linger
148
+ return true if linger_enabled?
149
+
150
+ system("loginctl", "enable-linger", ENV["USER"])
151
+ end
152
+
153
+ # Disable linger for the current user
154
+ #
155
+ # Prevents user services from starting at boot time.
156
+ # Does nothing if already disabled.
157
+ #
158
+ # @return [Boolean] True on success or already disabled, false on failure
159
+ def disable_linger
160
+ return true unless linger_enabled?
161
+
162
+ system("loginctl", "disable-linger", ENV["USER"])
163
+ end
164
+
165
+ # Start the service
166
+ #
167
+ # @raise [SystemdError] If systemd unavailable or service not installed
168
+ # @return [Boolean] True on success
169
+ def start
170
+ raise SystemdError, "systemd is not available on this system" unless systemd_available?
171
+ raise SystemdError, "Service is not installed" unless installed?
172
+
173
+ systemctl("start", SERVICE_NAME)
174
+ true
175
+ end
176
+
177
+ # Stop the service
178
+ #
179
+ # @raise [SystemdError] If systemd unavailable
180
+ # @return [Boolean] True on success
181
+ def stop
182
+ raise SystemdError, "systemd is not available on this system" unless systemd_available?
183
+
184
+ systemctl("stop", SERVICE_NAME)
185
+ true
186
+ end
187
+
188
+ # Restart the service
189
+ #
190
+ # @raise [SystemdError] If systemd unavailable or service not installed
191
+ # @return [Boolean] True on success
192
+ def restart
193
+ raise SystemdError, "systemd is not available on this system" unless systemd_available?
194
+ raise SystemdError, "Service is not installed" unless installed?
195
+
196
+ systemctl("restart", SERVICE_NAME)
197
+ true
198
+ end
199
+
200
+ # Get service status information
201
+ #
202
+ # @return [Hash] Status with :installed, :enabled, :active, :message keys
203
+ def status
204
+ {
205
+ installed: installed?,
206
+ enabled: enabled?,
207
+ active: active?,
208
+ message: status_message
209
+ }
210
+ end
211
+
212
+ private
213
+
214
+ # Get the systemd user directory path
215
+ #
216
+ # @return [String] Path to ~/.config/systemd/user
217
+ def systemd_user_dir
218
+ File.join(Dir.home, ".config", "systemd", "user")
219
+ end
220
+
221
+ # Get the full path to the service file
222
+ #
223
+ # @return [String] Path to service file
224
+ def service_file_path
225
+ File.join(systemd_user_dir, SERVICE_NAME)
226
+ end
227
+
228
+ # Generate the service file content
229
+ #
230
+ # @return [String] systemd service unit content
231
+ def service_template
232
+ <<~SERVICE
233
+ [Unit]
234
+ Description=botiasloop - AI Agent Gateway
235
+ Documentation=https://github.com/anomalyco/botiasloop
236
+ After=network.target
237
+
238
+ [Service]
239
+ Type=simple
240
+ ExecStart=#{executable_path} gateway
241
+ Restart=on-failure
242
+ RestartSec=5
243
+ StandardOutput=journal
244
+ StandardError=journal
245
+ Environment="PATH=#{ruby_bin_path}:/usr/local/bin:/usr/bin:/bin"
246
+
247
+ [Install]
248
+ WantedBy=multi-user.target
249
+ SERVICE
250
+ end
251
+
252
+ # Get the path to the botiasloop executable
253
+ #
254
+ # @return [String] Path to botiasloop binary
255
+ def executable_path
256
+ Gem.bin_path("botiasloop", "botiasloop")
257
+ rescue Gem::Exception
258
+ # Fallback to searching in PATH
259
+ `which botiasloop 2>/dev/null`.strip
260
+ end
261
+
262
+ # Get the Ruby bin directory for PATH
263
+ #
264
+ # @return [String] Path to Ruby bin directory
265
+ def ruby_bin_path
266
+ File.dirname(RbConfig.ruby)
267
+ end
268
+
269
+ # Execute a systemctl command
270
+ #
271
+ # @param args [Array<String>] Arguments to pass to systemctl
272
+ # @return [Boolean] True if command succeeded
273
+ def systemctl(*args)
274
+ system("systemctl", "--user", *args)
275
+ end
276
+
277
+ # Execute a systemctl command quietly (no output)
278
+ #
279
+ # @param args [Array<String>] Arguments to pass to systemctl
280
+ # @return [Boolean] True if command succeeded
281
+ def systemctl_quiet(*args)
282
+ system("systemctl", "--user", *args, out: "/dev/null", err: "/dev/null")
283
+ end
284
+
285
+ # Get a human-readable status message
286
+ #
287
+ # @return [String] Status description
288
+ def status_message
289
+ if !installed?
290
+ "Service not installed"
291
+ elsif active?
292
+ "Service is running"
293
+ elsif enabled?
294
+ "Service enabled but stopped"
295
+ else
296
+ "Service installed but disabled"
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Botiasloop
6
+ class Tool < RubyLLM::Tool
7
+ # Auto-generate tool name from class name in snake_case
8
+ # Example: WebSearch -> "web_search", MyCustomTool -> "my_custom_tool"
9
+ def self.tool_name
10
+ name
11
+ .split("::")
12
+ .last
13
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
14
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
15
+ .downcase
16
+ end
17
+
18
+ # Override RubyLLM's name method to use our tool_name
19
+ # This ensures the provider receives the correct tool name in schemas
20
+ def name
21
+ self.class.tool_name
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Tools
5
+ class Registry
6
+ attr_reader :tools
7
+
8
+ EMPTY_PARAMETERS_SCHEMA = {
9
+ "type" => "object",
10
+ "properties" => {},
11
+ "required" => [],
12
+ "additionalProperties" => false,
13
+ "strict" => true
14
+ }.freeze
15
+
16
+ def initialize
17
+ @tools = {}
18
+ @tool_instances = {}
19
+ end
20
+
21
+ # Register a tool class
22
+ #
23
+ # @param tool_class [Class] Tool class to register
24
+ # @param args [Hash] Arguments to pass to tool constructor
25
+ def register(tool_class, **args)
26
+ @tools[tool_class.tool_name] = tool_class
27
+ @tool_instances[tool_class.tool_name] = args
28
+ end
29
+
30
+ # Deregister a tool by name
31
+ #
32
+ # @param name [String] Tool name to deregister
33
+ def deregister(name)
34
+ @tools.delete(name)
35
+ @tool_instances.delete(name)
36
+ end
37
+
38
+ # Generate OpenAI-compatible tool schemas
39
+ # @return [Hash] Hash of tool instances keyed by tool name symbol
40
+ def schemas
41
+ @tools.transform_values do |tool_class|
42
+ args = @tool_instances[tool_class.tool_name]
43
+ args ? tool_class.new(**args) : tool_class.new
44
+ end
45
+ end
46
+
47
+ # @return [Array<Class>] Array of registered tool classes
48
+ def tool_classes
49
+ @tools.values
50
+ end
51
+
52
+ # Execute a tool by name
53
+ #
54
+ # @param name [String] Tool name
55
+ # @param arguments [Hash] Tool arguments
56
+ # @return [Hash] Tool result
57
+ # @raise [Error] If tool not found
58
+ def execute(name, arguments)
59
+ tool_class = @tools[name]
60
+ raise Error, "Unknown tool: #{name}" unless tool_class
61
+
62
+ args = @tool_instances[name]
63
+ tool = args ? tool_class.new(**args) : tool_class.new
64
+ tool.execute(**arguments.transform_keys(&:to_sym))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../tool"
5
+
6
+ module Botiasloop
7
+ module Tools
8
+ class Shell < Tool
9
+ description "Execute a shell command and return the output"
10
+ param :command, type: :string, desc: "The shell command to execute", required: true
11
+
12
+ # Execute a shell command
13
+ #
14
+ # @param command [String] Shell command to execute
15
+ # @return [Hash] Result with stdout, stderr, exit_code, and success?
16
+ def execute(command:)
17
+ stdout, stderr, status = Open3.capture3(command)
18
+ Result.new(stdout, stderr, status.exitstatus).to_h
19
+ end
20
+
21
+ # Result wrapper for shell execution
22
+ class Result
23
+ attr_reader :stdout, :stderr, :exit_code
24
+
25
+ def initialize(stdout, stderr, exit_code)
26
+ @stdout = stdout
27
+ @stderr = stderr
28
+ @exit_code = exit_code
29
+ end
30
+
31
+ def success?
32
+ @exit_code == 0
33
+ end
34
+
35
+ def to_s
36
+ "Exit: #{@exit_code}\nStdout:\n#{@stdout}\nStderr:\n#{@stderr}"
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ stdout: @stdout,
42
+ stderr: @stderr,
43
+ exit_code: @exit_code,
44
+ success?: success?
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../tool"
7
+
8
+ module Botiasloop
9
+ module Tools
10
+ class WebSearch < Tool
11
+ description "Search the web using SearXNG"
12
+ param :query, type: :string, desc: "The search query", required: true
13
+
14
+ # Initialize with SearXNG URL
15
+ #
16
+ # @param searxng_url [String] SearXNG instance URL
17
+ def initialize(searxng_url)
18
+ @searxng_url = searxng_url
19
+ end
20
+
21
+ # Execute web search
22
+ #
23
+ # @param query [String] Search query
24
+ # @return [Hash] Search results
25
+ # @raise [Error] On HTTP or connection errors
26
+ def execute(query:)
27
+ uri = URI("#{@searxng_url}/search")
28
+ uri.query = URI.encode_www_form("q" => query, "format" => "json")
29
+
30
+ response = Net::HTTP.get_response(uri)
31
+
32
+ unless response.is_a?(Net::HTTPSuccess)
33
+ raise Error, "Search failed: HTTP #{response.code}"
34
+ end
35
+
36
+ data = JSON.parse(response.body)
37
+ Result.new(data["results"] || []).to_h
38
+ rescue Errno::ECONNREFUSED => e
39
+ raise Error, "Search failed: #{e.message}"
40
+ rescue JSON::ParserError => e
41
+ raise Error, "Search failed: Invalid JSON response - #{e.message}"
42
+ end
43
+
44
+ # Result wrapper for search results
45
+ class Result
46
+ attr_reader :results
47
+
48
+ def initialize(results)
49
+ @results = results
50
+ end
51
+
52
+ def to_s
53
+ @results.map do |r|
54
+ "#{r["title"]}\n#{r["url"]}\n#{r["content"]}"
55
+ end.join("\n\n")
56
+ end
57
+
58
+ def to_h
59
+ {results: @results}
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ VERSION = "0.0.1"
5
+ end
data/lib/botiasloop.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "botiasloop/version"
4
+ require_relative "botiasloop/config"
5
+ require_relative "botiasloop/database"
6
+
7
+ require_relative "botiasloop/conversation"
8
+ require_relative "botiasloop/conversation_manager"
9
+ require_relative "botiasloop/tool"
10
+ require_relative "botiasloop/tools/registry"
11
+ require_relative "botiasloop/tools/shell"
12
+ require_relative "botiasloop/tools/web_search"
13
+ require_relative "botiasloop/skills/skill"
14
+ require_relative "botiasloop/skills/loader"
15
+ require_relative "botiasloop/skills/registry"
16
+ require_relative "botiasloop/commands"
17
+ require_relative "botiasloop/loop"
18
+ require_relative "botiasloop/agent"
19
+ require_relative "botiasloop/channels"
20
+ require_relative "botiasloop/channels/base"
21
+ require_relative "botiasloop/channels/cli"
22
+ require_relative "botiasloop/channels/telegram"
23
+ require_relative "botiasloop/channels_manager"
24
+ require_relative "botiasloop/systemd_service"
25
+
26
+ module Botiasloop
27
+ class Error < StandardError; end
28
+
29
+ # Error raised for systemd service operation failures
30
+ class SystemdError < Error; end
31
+
32
+ # @return [String] Root directory of the gem
33
+ def self.root
34
+ File.dirname(__dir__)
35
+ end
36
+
37
+ class MaxIterationsExceeded < Error
38
+ attr_reader :max_iterations
39
+
40
+ def initialize(max_iterations)
41
+ @max_iterations = max_iterations
42
+ super("I've reached my thinking limit (#{max_iterations} iterations). Please try a more specific question.")
43
+ end
44
+ end
45
+ end