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.
- checksums.yaml +7 -0
- data/Gemfile +32 -0
- data/LICENSE +21 -0
- data/README.md +530 -0
- data/bin/nanobot +7 -0
- data/bin/record-integrations +38 -0
- data/lib/nanobot/agent/context.rb +132 -0
- data/lib/nanobot/agent/loop.rb +336 -0
- data/lib/nanobot/agent/memory.rb +131 -0
- data/lib/nanobot/agent/tools/filesystem.rb +241 -0
- data/lib/nanobot/agent/tools/schedule.rb +94 -0
- data/lib/nanobot/agent/tools/shell.rb +181 -0
- data/lib/nanobot/agent/tools/web.rb +216 -0
- data/lib/nanobot/bus/events.rb +70 -0
- data/lib/nanobot/bus/message_bus.rb +152 -0
- data/lib/nanobot/channels/base.rb +94 -0
- data/lib/nanobot/channels/discord.rb +64 -0
- data/lib/nanobot/channels/email.rb +253 -0
- data/lib/nanobot/channels/gateway.rb +105 -0
- data/lib/nanobot/channels/manager.rb +128 -0
- data/lib/nanobot/channels/slack.rb +162 -0
- data/lib/nanobot/channels/telegram.rb +94 -0
- data/lib/nanobot/cli/commands.rb +444 -0
- data/lib/nanobot/config/loader.rb +204 -0
- data/lib/nanobot/config/schema.rb +296 -0
- data/lib/nanobot/providers/base.rb +43 -0
- data/lib/nanobot/providers/rubyllm_provider.rb +254 -0
- data/lib/nanobot/scheduler/service.rb +108 -0
- data/lib/nanobot/scheduler/store.rb +233 -0
- data/lib/nanobot/session/manager.rb +225 -0
- data/lib/nanobot/version.rb +6 -0
- data/lib/nanobot.rb +23 -0
- metadata +176 -0
|
@@ -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
|