nanobot 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.
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require_relative 'schema'
6
+
7
+ module Nanobot
8
+ module Config
9
+ # Loader handles loading and saving configuration from/to JSON files
10
+ class Loader
11
+ DEFAULT_CONFIG_PATH = File.expand_path('~/.nanobot/config.json').freeze
12
+
13
+ class << self
14
+ # Load configuration from file
15
+ # @param path [String, nil] path to config file (default: ~/.nanobot/config.json)
16
+ # @return [Config]
17
+ def load(path = nil)
18
+ config_path = Pathname.new(path || DEFAULT_CONFIG_PATH)
19
+
20
+ unless config_path.exist?
21
+ # Return default config if file doesn't exist
22
+ return Config.new
23
+ end
24
+
25
+ begin
26
+ data = JSON.parse(config_path.read, symbolize_names: true)
27
+ Config.new(**data)
28
+ rescue StandardError => e
29
+ raise "Error loading config from #{config_path}: #{e.message}"
30
+ end
31
+ end
32
+
33
+ # Save configuration to file
34
+ # @param config [Config] configuration to save
35
+ # @param path [String, nil] path to save to (default: ~/.nanobot/config.json)
36
+ def save(config, path = nil)
37
+ config_path = Pathname.new(path || DEFAULT_CONFIG_PATH)
38
+
39
+ # Ensure directory exists
40
+ config_path.dirname.mkpath unless config_path.dirname.exist?
41
+
42
+ # Convert to hash and save as JSON
43
+ data = config_to_hash(config)
44
+
45
+ config_path.write(JSON.pretty_generate(data))
46
+ FileUtils.chmod(0o600, config_path)
47
+ end
48
+
49
+ # Get config path
50
+ # @param path [String, nil] custom path or nil for default
51
+ # @return [Pathname]
52
+ def get_config_path(path = nil)
53
+ Pathname.new(path || DEFAULT_CONFIG_PATH)
54
+ end
55
+
56
+ # Check if config exists
57
+ # @param path [String, nil] custom path or nil for default
58
+ # @return [Boolean]
59
+ def exists?(path = nil)
60
+ get_config_path(path).exist?
61
+ end
62
+
63
+ # Create default config file with helpful placeholders
64
+ # @param path [String, nil] custom path or nil for default
65
+ # @return [Config]
66
+ def create_default(path = nil)
67
+ config = Config.new(
68
+ providers: {
69
+ anthropic: {
70
+ api_key: 'sk-ant-api03-...'
71
+ },
72
+ openai: {
73
+ api_key: 'sk-...'
74
+ },
75
+ openrouter: {
76
+ api_key: 'sk-or-v1-...',
77
+ api_base: 'https://openrouter.ai/api/v1'
78
+ }
79
+ },
80
+ tools: {
81
+ web: {
82
+ search: {
83
+ api_key: 'BSA...'
84
+ }
85
+ }
86
+ }
87
+ )
88
+ save(config, path)
89
+ config
90
+ end
91
+
92
+ private
93
+
94
+ # Convert config to hash for JSON serialization
95
+ # @param config [Config] configuration object
96
+ # @return [Hash] JSON-serializable hash
97
+ def config_to_hash(config)
98
+ hash = {
99
+ providers: providers_to_hash(config.providers),
100
+ provider: config.provider,
101
+ agents: agents_to_hash(config.agents),
102
+ tools: tools_to_hash(config.tools)
103
+ }
104
+ channels = channels_to_hash(config.channels)
105
+ hash[:channels] = channels unless channels.empty?
106
+ hash
107
+ end
108
+
109
+ # Serialize providers, omitting unconfigured ones
110
+ # @param providers [ProvidersConfig]
111
+ # @return [Hash]
112
+ def providers_to_hash(providers)
113
+ hash = {}
114
+ providers.each do |key, provider|
115
+ hash[key] = {
116
+ api_key: provider.api_key,
117
+ api_base: provider.api_base,
118
+ extra_headers: provider.extra_headers
119
+ }.compact
120
+ end
121
+ hash
122
+ end
123
+
124
+ def agents_to_hash(agents)
125
+ {
126
+ defaults: {
127
+ model: agents.defaults.model,
128
+ workspace: agents.defaults.workspace,
129
+ max_tokens: agents.defaults.max_tokens,
130
+ temperature: agents.defaults.temperature,
131
+ max_tool_iterations: agents.defaults.max_tool_iterations
132
+ }
133
+ }
134
+ end
135
+
136
+ def tools_to_hash(tools)
137
+ {
138
+ web: {
139
+ search: {
140
+ api_key: tools.web.search.api_key
141
+ }.compact
142
+ },
143
+ exec: {
144
+ timeout: tools.exec.timeout
145
+ },
146
+ restrict_to_workspace: tools.restrict_to_workspace
147
+ }
148
+ end
149
+
150
+ # Serialize channels, omitting disabled ones
151
+ # @param channels [ChannelsConfig, nil]
152
+ # @return [Hash]
153
+ def channels_to_hash(channels)
154
+ return {} unless channels
155
+
156
+ hash = {}
157
+ hash[:telegram] = telegram_to_hash(channels.telegram)
158
+ hash[:discord] = discord_to_hash(channels.discord)
159
+ hash[:gateway] = gateway_to_hash(channels.gateway)
160
+ hash[:slack] = slack_to_hash(channels.slack)
161
+ hash[:email] = email_to_hash(channels.email)
162
+ hash.compact
163
+ end
164
+
165
+ def telegram_to_hash(telegram)
166
+ return unless telegram&.enabled
167
+
168
+ { enabled: telegram.enabled, token: telegram.token,
169
+ allow_from: telegram.allow_from, proxy: telegram.proxy }.compact
170
+ end
171
+
172
+ def discord_to_hash(discord)
173
+ return unless discord&.enabled
174
+
175
+ { enabled: discord.enabled, token: discord.token,
176
+ allow_from: discord.allow_from }.compact
177
+ end
178
+
179
+ def gateway_to_hash(gateway)
180
+ return unless gateway&.enabled
181
+
182
+ { enabled: gateway.enabled, host: gateway.host,
183
+ port: gateway.port, auth_token: gateway.auth_token }.compact
184
+ end
185
+
186
+ def slack_to_hash(slack)
187
+ return unless slack&.enabled
188
+
189
+ { enabled: slack.enabled, bot_token: slack.bot_token,
190
+ app_token: slack.app_token, group_policy: slack.group_policy,
191
+ group_allow_from: slack.group_allow_from,
192
+ dm: { enabled: slack.dm.enabled, policy: slack.dm.policy,
193
+ allow_from: slack.dm.allow_from } }.compact
194
+ end
195
+
196
+ def email_to_hash(email)
197
+ return unless email&.enabled
198
+
199
+ email.to_h.compact
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nanobot
4
+ module Config
5
+ # Configuration schema classes using plain Ruby hashes and structs
6
+
7
+ # Credentials and endpoint for a single LLM provider
8
+ ProviderConfig = Struct.new(:api_key, :api_base, :extra_headers, keyword_init: true) do
9
+ # @param api_key [String, nil] API authentication key
10
+ # @param api_base [String, nil] custom API base URL
11
+ # @param extra_headers [Hash, nil] additional HTTP headers sent with requests
12
+ def initialize(api_key: nil, api_base: nil, extra_headers: nil)
13
+ super
14
+ end
15
+ end
16
+
17
+ # Collection of LLM provider configurations, keyed by provider name.
18
+ # Accepts any provider name so new RubyLLM backends work without code changes.
19
+ class ProvidersConfig
20
+ # @param kwargs [Hash] provider name => settings hash pairs
21
+ def initialize(**kwargs)
22
+ @providers = {}
23
+ kwargs.each do |key, value|
24
+ @providers[key.to_sym] = value.is_a?(ProviderConfig) ? value : ProviderConfig.new(**value)
25
+ end
26
+ end
27
+
28
+ # Iterate over all configured providers
29
+ # @yieldparam key [Symbol] provider name
30
+ # @yieldparam config [ProviderConfig] provider configuration
31
+ def each(&)
32
+ @providers.each(&)
33
+ end
34
+
35
+ # @return [Boolean] true when no providers are configured
36
+ def empty?
37
+ @providers.empty?
38
+ end
39
+
40
+ def respond_to_missing?(_name, _include_private = false)
41
+ true
42
+ end
43
+
44
+ def method_missing(name, *args)
45
+ return super if name.end_with?('=') || !args.empty?
46
+
47
+ @providers[name.to_sym]
48
+ end
49
+ end
50
+
51
+ # Default settings applied to all agents unless overridden
52
+ AgentDefaults = Struct.new(
53
+ :model,
54
+ :workspace,
55
+ :max_tokens,
56
+ :temperature,
57
+ :max_tool_iterations,
58
+ :log_level,
59
+ keyword_init: true
60
+ ) do
61
+ # @param model [String] default LLM model identifier
62
+ # @param workspace [String] path to agent workspace directory
63
+ # @param max_tokens [Integer] maximum tokens per LLM response
64
+ # @param temperature [Float] sampling temperature (0.0-1.0)
65
+ # @param max_tool_iterations [Integer] maximum tool call rounds per turn
66
+ # @param log_level [String] logging verbosity (debug, info, warn, error)
67
+ def initialize(
68
+ model: 'claude-haiku-4-5',
69
+ workspace: '~/.nanobot/workspace',
70
+ max_tokens: 4096,
71
+ temperature: 0.7,
72
+ max_tool_iterations: 20,
73
+ log_level: 'info'
74
+ )
75
+ super
76
+ end
77
+ end
78
+
79
+ # Top-level agents section wrapping agent defaults
80
+ AgentsConfig = Struct.new(:defaults, keyword_init: true) do
81
+ # @param defaults [Hash] agent default settings (see AgentDefaults)
82
+ def initialize(defaults: {})
83
+ super(defaults: AgentDefaults.new(**defaults))
84
+ end
85
+ end
86
+
87
+ # Web search tool API settings
88
+ WebSearchConfig = Struct.new(:api_key, keyword_init: true) do
89
+ # @param api_key [String, nil] Brave Search API key
90
+ def initialize(api_key: nil)
91
+ super
92
+ end
93
+ end
94
+
95
+ # Shell command execution tool settings
96
+ ExecToolConfig = Struct.new(:timeout, keyword_init: true) do
97
+ # @param timeout [Integer] maximum seconds before killing the process
98
+ def initialize(timeout: 60)
99
+ super
100
+ end
101
+ end
102
+
103
+ # Aggregate configuration for all agent tools
104
+ ToolsConfig = Struct.new(:web, :exec, :restrict_to_workspace, keyword_init: true) do
105
+ # @param web [Hash] web tool settings (nested :search key)
106
+ # @param exec [Hash] exec tool settings (see ExecToolConfig)
107
+ # @param restrict_to_workspace [Boolean] limit file tools to workspace directory
108
+ def initialize(web: {}, exec: {}, restrict_to_workspace: true)
109
+ web_config = web.is_a?(Hash) ? web : {}
110
+ exec_config = exec.is_a?(Hash) ? exec : {}
111
+
112
+ super(
113
+ web: Struct.new(:search, keyword_init: true).new(
114
+ search: WebSearchConfig.new(**(web_config[:search] || {}))
115
+ ),
116
+ exec: ExecToolConfig.new(**exec_config),
117
+ restrict_to_workspace: restrict_to_workspace
118
+ )
119
+ end
120
+ end
121
+
122
+ # Telegram channel configuration
123
+ TelegramConfig = Struct.new(:enabled, :token, :allow_from, :proxy, keyword_init: true) do
124
+ # @param enabled [Boolean] whether the Telegram channel is active
125
+ # @param token [String, nil] Telegram Bot API token
126
+ # @param allow_from [Array<String>] allowed usernames or chat IDs
127
+ # @param proxy [String, nil] HTTP proxy URL for Telegram API requests
128
+ def initialize(enabled: false, token: nil, allow_from: [], proxy: nil)
129
+ super
130
+ end
131
+ end
132
+
133
+ # Discord channel configuration
134
+ DiscordConfig = Struct.new(:enabled, :token, :allow_from, keyword_init: true) do
135
+ # @param enabled [Boolean] whether the Discord channel is active
136
+ # @param token [String, nil] Discord bot token
137
+ # @param allow_from [Array<String>] allowed user IDs or usernames
138
+ def initialize(enabled: false, token: nil, allow_from: [])
139
+ super
140
+ end
141
+ end
142
+
143
+ # HTTP Gateway channel configuration
144
+ GatewayConfig = Struct.new(:enabled, :host, :port, :auth_token, keyword_init: true) do
145
+ # @param enabled [Boolean] whether the HTTP gateway is active
146
+ # @param host [String] bind address for the HTTP server
147
+ # @param port [Integer] listen port for the HTTP server
148
+ # @param auth_token [String, nil] bearer token for authenticating requests
149
+ def initialize(enabled: false, host: '127.0.0.1', port: 18_790, auth_token: nil)
150
+ super
151
+ end
152
+ end
153
+
154
+ # Slack direct message policy configuration
155
+ SlackDMConfig = Struct.new(:enabled, :policy, :allow_from, keyword_init: true) do
156
+ # @param enabled [Boolean] whether DM handling is active
157
+ # @param policy [String] access policy: "open" or "restricted"
158
+ # @param allow_from [Array<String>] allowed Slack user IDs when policy is restricted
159
+ def initialize(enabled: true, policy: 'open', allow_from: [])
160
+ super
161
+ end
162
+ end
163
+
164
+ # Slack channel configuration
165
+ SlackConfig = Struct.new(
166
+ :enabled, :bot_token, :app_token, :group_policy,
167
+ :group_allow_from, :dm, keyword_init: true
168
+ ) do
169
+ # @param enabled [Boolean] whether the Slack channel is active
170
+ # @param bot_token [String, nil] Slack Bot User OAuth token (xoxb-)
171
+ # @param app_token [String, nil] Slack App-Level token for Socket Mode (xapp-)
172
+ # @param group_policy [String] group message policy: "mention" or "all"
173
+ # @param group_allow_from [Array<String>] allowed channel IDs for group messages
174
+ # @param dm [Hash] direct message settings (see SlackDMConfig)
175
+ def initialize(
176
+ enabled: false, bot_token: nil, app_token: nil,
177
+ group_policy: 'mention', group_allow_from: [], dm: {}
178
+ )
179
+ dm_config = dm.is_a?(Hash) ? dm : {}
180
+ super(
181
+ enabled: enabled, bot_token: bot_token, app_token: app_token,
182
+ group_policy: group_policy, group_allow_from: group_allow_from,
183
+ dm: SlackDMConfig.new(**dm_config)
184
+ )
185
+ end
186
+ end
187
+
188
+ # Email channel configuration for IMAP polling and SMTP replies
189
+ EmailConfig = Struct.new(
190
+ :enabled, :consent_granted,
191
+ :imap_host, :imap_port, :imap_username, :imap_password,
192
+ :imap_mailbox, :imap_use_ssl,
193
+ :smtp_host, :smtp_port, :smtp_username, :smtp_password,
194
+ :smtp_use_tls, :smtp_use_ssl, :from_address,
195
+ :auto_reply_enabled, :poll_interval_seconds, :mark_seen,
196
+ :max_body_chars, :subject_prefix, :allow_from,
197
+ keyword_init: true
198
+ ) do
199
+ # @param enabled [Boolean] whether the email channel is active
200
+ # @param consent_granted [Boolean] user consent for automated email replies
201
+ # @param imap_host [String, nil] IMAP server hostname
202
+ # @param imap_port [Integer] IMAP server port
203
+ # @param imap_username [String, nil] IMAP login username
204
+ # @param imap_password [String, nil] IMAP login password
205
+ # @param imap_mailbox [String] IMAP mailbox to poll
206
+ # @param imap_use_ssl [Boolean] use SSL for IMAP connection
207
+ # @param smtp_host [String, nil] SMTP server hostname
208
+ # @param smtp_port [Integer] SMTP server port
209
+ # @param smtp_username [String, nil] SMTP login username
210
+ # @param smtp_password [String, nil] SMTP login password
211
+ # @param smtp_use_tls [Boolean] use STARTTLS for SMTP
212
+ # @param smtp_use_ssl [Boolean] use implicit SSL for SMTP
213
+ # @param from_address [String, nil] sender address for outgoing replies
214
+ # @param auto_reply_enabled [Boolean] automatically reply to incoming emails
215
+ # @param poll_interval_seconds [Integer] seconds between IMAP polls
216
+ # @param mark_seen [Boolean] mark processed emails as seen
217
+ # @param max_body_chars [Integer] truncate email body beyond this length
218
+ # @param subject_prefix [String] prefix prepended to reply subjects
219
+ # @param allow_from [Array<String>] allowed sender addresses
220
+ def initialize(
221
+ enabled: false, consent_granted: false,
222
+ imap_host: nil, imap_port: 993, imap_username: nil, imap_password: nil,
223
+ imap_mailbox: 'INBOX', imap_use_ssl: true,
224
+ smtp_host: nil, smtp_port: 587, smtp_username: nil, smtp_password: nil,
225
+ smtp_use_tls: true, smtp_use_ssl: false, from_address: nil,
226
+ auto_reply_enabled: true, poll_interval_seconds: 30, mark_seen: true,
227
+ max_body_chars: 12_000, subject_prefix: 'Re: ', allow_from: []
228
+ )
229
+ super
230
+ end
231
+ end
232
+ # Collection of all messaging channel configurations
233
+ ChannelsConfig = Struct.new(:telegram, :discord, :gateway, :slack, :email, keyword_init: true) do
234
+ # @param telegram [Hash] Telegram channel settings (see TelegramConfig)
235
+ # @param discord [Hash] Discord channel settings (see DiscordConfig)
236
+ # @param gateway [Hash] HTTP gateway settings (see GatewayConfig)
237
+ # @param slack [Hash] Slack channel settings (see SlackConfig)
238
+ # @param email [Hash] email channel settings (see EmailConfig)
239
+ def initialize(telegram: {}, discord: {}, gateway: {}, slack: {}, email: {})
240
+ super(
241
+ telegram: TelegramConfig.new(**(telegram.is_a?(Hash) ? telegram : {})),
242
+ discord: DiscordConfig.new(**(discord.is_a?(Hash) ? discord : {})),
243
+ gateway: GatewayConfig.new(**(gateway.is_a?(Hash) ? gateway : {})),
244
+ slack: SlackConfig.new(**(slack.is_a?(Hash) ? slack : {})),
245
+ email: EmailConfig.new(**(email.is_a?(Hash) ? email : {}))
246
+ )
247
+ end
248
+ end
249
+
250
+ # Scheduler service configuration
251
+ SchedulerConfig = Struct.new(:enabled, :tick_interval, keyword_init: true) do
252
+ # @param enabled [Boolean] whether the scheduler service is active
253
+ # @param tick_interval [Integer] seconds between schedule evaluation ticks
254
+ def initialize(enabled: true, tick_interval: 15)
255
+ super
256
+ end
257
+ end
258
+
259
+ # Root configuration object holding all Nanobot settings
260
+ Config = Struct.new(:providers, :provider, :agents, :tools, :channels, :scheduler, keyword_init: true) do
261
+ # @param providers [Hash] provider credentials (see ProvidersConfig)
262
+ # @param provider [String] name of the active provider (e.g. "anthropic", "openai")
263
+ # @param agents [Hash] agent settings (see AgentsConfig)
264
+ # @param tools [Hash] tool settings (see ToolsConfig)
265
+ # @param channels [Hash] channel settings (see ChannelsConfig)
266
+ # @param scheduler [Hash] scheduler settings (see SchedulerConfig)
267
+ def initialize(providers: {}, provider: 'anthropic', agents: {}, tools: {}, channels: {}, scheduler: {},
268
+ **_rest)
269
+ channels_config = channels.is_a?(Hash) ? channels : {}
270
+ scheduler_config = scheduler.is_a?(Hash) ? scheduler : {}
271
+ super(
272
+ providers: ProvidersConfig.new(**providers),
273
+ provider: provider.to_s,
274
+ agents: AgentsConfig.new(**agents),
275
+ tools: ToolsConfig.new(**tools),
276
+ channels: ChannelsConfig.new(**channels_config),
277
+ scheduler: SchedulerConfig.new(**scheduler_config)
278
+ )
279
+ end
280
+
281
+ # Get the API key for the selected provider
282
+ # @return [String, nil]
283
+ def api_key
284
+ selected = providers.send(provider.to_sym) if providers.respond_to?(provider.to_sym)
285
+ selected&.api_key
286
+ end
287
+
288
+ # Get the API base for the selected provider
289
+ # @return [String, nil]
290
+ def api_base
291
+ selected = providers.send(provider.to_sym) if providers.respond_to?(provider.to_sym)
292
+ selected&.api_base
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nanobot
4
+ module Providers
5
+ # ToolCallRequest represents a tool call from the LLM
6
+ ToolCallRequest = Struct.new(:id, :name, :arguments, :thought_signature, keyword_init: true)
7
+
8
+ # LLMResponse represents the response from an LLM
9
+ LLMResponse = Struct.new(:content, :tool_calls, :finish_reason, keyword_init: true) do
10
+ def initialize(content: nil, tool_calls: nil, finish_reason: nil)
11
+ super(
12
+ content: content || '',
13
+ tool_calls: tool_calls || [],
14
+ finish_reason: finish_reason
15
+ )
16
+ end
17
+
18
+ def tool_calls?
19
+ tool_calls && !tool_calls.empty?
20
+ end
21
+ end
22
+
23
+ # Base class for LLM providers
24
+ class LLMProvider
25
+ # Chat with the LLM
26
+ # @param messages [Array<Hash>] array of message hashes with :role and :content
27
+ # @param tools [Array<Hash>] array of tool definitions in OpenAI format
28
+ # @param model [String] model identifier
29
+ # @param max_tokens [Integer] maximum tokens to generate
30
+ # @param temperature [Float] sampling temperature
31
+ # @return [LLMResponse]
32
+ def chat(messages:, tools: nil, model: nil, max_tokens: 4096, temperature: 0.7)
33
+ raise NotImplementedError, "#{self.class} must implement #chat"
34
+ end
35
+
36
+ # Get the default model for this provider
37
+ # @return [String]
38
+ def default_model
39
+ raise NotImplementedError, "#{self.class} must implement #default_model"
40
+ end
41
+ end
42
+ end
43
+ end