kodo-bot 0.2.1 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28db2b743e15b691e4c20712a38e822add6c2e559edf75780a9a82887695dfee
4
- data.tar.gz: ddc6dc895a4b102506c2a68f5d497053dba8c6bf34ea09150ba47d25e3c8eab8
3
+ metadata.gz: f1b635240737c255adbfeae59a3a49414133f0b5a01897e92f44628334fe0b00
4
+ data.tar.gz: 04731bbe6a6419939afb2851c44ff328556a5ac3e2f22c8f9918a3a9e92a93b2
5
5
  SHA512:
6
- metadata.gz: f475e0f066e08ae275093769a245a1a8e2d88147a8a0aa76985968365fd84cb12d530cfdee3c5afb69ca177f56d24437a725fe9ce4b451a3b61778c183d2745f
7
- data.tar.gz: 703e9236a4338a9441e28aadb0d00ce2ab43e9f689fa79ff7fea63578e852c4682bd3ac4384706ae4f01d3acc376a1638791032def2f6d185b5cb2d2e4d53410
6
+ metadata.gz: 41c5de2afa4352882563f3b3ac56652ef1bad44729b449a5f74df910c876b31d4c34e13940cd2a6679a3192877b5370daaeb08f75b0b8085894ea4f47101bcd8
7
+ data.tar.gz: b4984ce2c8e2405b6706f631d3621650b8ad16b66d8fa80d209e0d1ad2cd65084eed0ebe49564a0a5eb79b7187bdd674677efe5a2a2052e5c53555277c96fe45
data/bin/kodo CHANGED
@@ -1,32 +1,32 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative "../lib/kodo"
4
+ require_relative '../lib/kodo'
5
5
 
6
6
  module Kodo
7
7
  class CLI
8
8
  COMMANDS = {
9
- "start" => "Start the Kodo daemon",
10
- "chat" => "Chat with Kodo directly in the terminal",
11
- "memories" => "List what Kodo remembers about you",
12
- "reminders" => "List active reminders",
13
- "status" => "Show daemon status",
14
- "version" => "Show version",
15
- "init" => "Create default config and prompt files in ~/.kodo/",
16
- "help" => "Show this help"
9
+ 'start' => 'Start the Kodo daemon',
10
+ 'chat' => 'Chat with Kodo directly in the terminal',
11
+ 'memories' => 'List what Kodo remembers about you',
12
+ 'reminders' => 'List active reminders',
13
+ 'status' => 'Show daemon status',
14
+ 'version' => 'Show version',
15
+ 'init' => 'Create default config and prompt files in ~/.kodo/',
16
+ 'help' => 'Show this help'
17
17
  }.freeze
18
18
 
19
19
  def run(args = ARGV)
20
- command = args.first || "help"
20
+ command = args.first || 'help'
21
21
 
22
22
  case command
23
- when "start" then start(args)
24
- when "chat" then chat
25
- when "memories" then memories
26
- when "reminders" then reminders
27
- when "init" then init
28
- when "version" then puts "kodo v#{VERSION}"
29
- when "status" then status
23
+ when 'start' then start(args)
24
+ when 'chat' then chat
25
+ when 'memories' then memories
26
+ when 'reminders' then reminders
27
+ when 'init' then init
28
+ when 'version' then puts "kodo v#{VERSION}"
29
+ when 'status' then status
30
30
  else help
31
31
  end
32
32
  end
@@ -36,11 +36,9 @@ module Kodo
36
36
  def start(args)
37
37
  interval = nil
38
38
  args.each do |arg|
39
- if arg.start_with?("--heartbeat-interval=")
40
- interval = arg.split("=", 2).last.to_i
41
- end
39
+ interval = arg.split('=', 2).last.to_i if arg.start_with?('--heartbeat-interval=')
42
40
  end
43
- if (idx = args.index("--heartbeat-interval")) && !args[idx + 1]&.start_with?("--")
41
+ if (idx = args.index('--heartbeat-interval')) && !args[idx + 1]&.start_with?('--')
44
42
  interval = args[idx + 1]&.to_i
45
43
  end
46
44
 
@@ -48,28 +46,45 @@ module Kodo
48
46
  daemon.start!
49
47
  end
50
48
 
51
- def chat
49
+ def chat # rubocop:disable Metrics/MethodLength
52
50
  Config.ensure_home_dir!
53
51
  assembler = PromptAssembler.new
54
52
  assembler.ensure_default_files!
55
- LLM.configure!(Kodo.config)
56
53
 
57
54
  passphrase = Kodo.config.memory_encryption? ? Kodo.config.memory_passphrase : nil
58
55
 
59
- puts "🥁 Kodo v#{VERSION} — direct chat mode"
60
- puts " Model: #{Kodo.config.llm_model}"
61
- puts " Type your message and press Enter. Ctrl+C to quit.\n\n"
62
-
63
56
  memory = Memory::Store.new(passphrase: passphrase)
64
57
  audit = Memory::Audit.new
65
58
  knowledge = Memory::Knowledge.new(passphrase: passphrase)
66
59
  reminder_store = Memory::Reminders.new(passphrase: passphrase)
60
+
61
+ secrets_store = Secrets::Store.new(passphrase: Kodo.config.secrets_passphrase)
62
+ broker = Secrets::Broker.new(store: secrets_store, audit: audit)
63
+ search_provider = resolve_chat_search_provider(broker)
64
+
65
+ LLM.configure!(Kodo.config, broker: broker)
66
+
67
+ puts "🥁 Kodo v#{VERSION} — direct chat mode"
68
+ puts " Model: #{Kodo.config.llm_model}"
69
+ puts " Type your message and press Enter. Ctrl+C to quit.\n\n"
70
+
71
+ router = nil
72
+
73
+ on_secret_stored = lambda do |_secret_name|
74
+ search_provider = resolve_chat_search_provider(broker)
75
+ router.reload_tools!(search_provider: search_provider)
76
+ LLM.configure!(Kodo.config, broker: broker)
77
+ end
78
+
67
79
  router = Router.new(
68
80
  memory: memory,
69
81
  audit: audit,
70
82
  prompt_assembler: assembler,
71
83
  knowledge: knowledge,
72
- reminders: reminder_store
84
+ reminders: reminder_store,
85
+ search_provider: search_provider,
86
+ broker: broker,
87
+ on_secret_stored: on_secret_stored
73
88
  )
74
89
  console = Channels::Console.new
75
90
  console.connect!
@@ -80,10 +95,10 @@ module Kodo
80
95
  break if input.nil? || input.empty?
81
96
 
82
97
  message = Message.new(
83
- channel_id: "console",
98
+ channel_id: 'console',
84
99
  sender: :user,
85
100
  content: input,
86
- metadata: { chat_id: "console" }
101
+ metadata: { chat_id: 'console' }
87
102
  )
88
103
 
89
104
  response = with_spinner { router.route(message, channel: console) }
@@ -100,15 +115,15 @@ module Kodo
100
115
 
101
116
  active = knowledge.all_active
102
117
  if active.empty?
103
- puts "Kodo has no stored memories yet."
104
- puts "Chat with Kodo and it will remember things you tell it."
118
+ puts 'Kodo has no stored memories yet.'
119
+ puts 'Chat with Kodo and it will remember things you tell it.'
105
120
  return
106
121
  end
107
122
 
108
123
  puts "Kodo's memories (#{active.length} facts):"
109
- puts ""
124
+ puts ''
110
125
 
111
- grouped = active.group_by { |f| f["category"] }
126
+ grouped = active.group_by { |f| f['category'] }
112
127
  Memory::Knowledge::VALID_CATEGORIES.each do |cat|
113
128
  facts = grouped[cat]
114
129
  next unless facts&.any?
@@ -118,7 +133,7 @@ module Kodo
118
133
  puts " - #{f['content']}"
119
134
  puts " id: #{f['id']} source: #{f['source']} #{f['created_at']}"
120
135
  end
121
- puts ""
136
+ puts ''
122
137
  end
123
138
  end
124
139
 
@@ -127,20 +142,20 @@ module Kodo
127
142
  passphrase = Kodo.config.memory_encryption? ? Kodo.config.memory_passphrase : nil
128
143
  reminder_store = Memory::Reminders.new(passphrase: passphrase)
129
144
 
130
- active = reminder_store.all_active.sort_by { |r| r["due_at"] }
145
+ active = reminder_store.all_active.sort_by { |r| r['due_at'] }
131
146
  if active.empty?
132
- puts "No active reminders."
147
+ puts 'No active reminders.'
133
148
  return
134
149
  end
135
150
 
136
151
  puts "Active reminders (#{active.length}):"
137
- puts ""
152
+ puts ''
138
153
 
139
154
  active.each do |r|
140
155
  puts " - #{r['content']}"
141
156
  puts " due: #{r['due_at']} id: #{r['id']}"
142
157
  end
143
- puts ""
158
+ puts ''
144
159
  end
145
160
 
146
161
  def init
@@ -148,64 +163,77 @@ module Kodo
148
163
  PromptAssembler.new.ensure_default_files!
149
164
 
150
165
  puts "✅ Kodo home directory: #{Kodo.home_dir}"
151
- puts ""
152
- puts " Created files:"
153
- puts " 📋 config.yml — LLM provider and channel settings"
154
- puts " 🎭 persona.md — personality and tone (make Kodo yours)"
155
- puts " 👤 user.md — tell Kodo about yourself"
156
- puts " 💓 pulse.md — what to notice during idle beats"
157
- puts " 🌱 origin.md — first-run onboarding conversation"
158
- puts ""
159
- puts " Quick start:"
160
- puts " 1. Set an LLM API key (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY)"
161
- puts " 2. Set TELEGRAM_BOT_TOKEN (get one from @BotFather on Telegram)"
162
- puts " 3. Enable Telegram in ~/.kodo/config.yml"
166
+ puts ''
167
+ puts ' Created files:'
168
+ puts ' 📋 config.yml — LLM provider and channel settings'
169
+ puts ' 🎭 persona.md — personality and tone (make Kodo yours)'
170
+ puts ' 👤 user.md — tell Kodo about yourself'
171
+ puts ' 💓 pulse.md — what to notice during idle beats'
172
+ puts ' 🌱 origin.md — first-run onboarding conversation'
173
+ puts ''
174
+ puts ' Quick start:'
175
+ puts ' 1. Set an LLM API key (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY)'
176
+ puts ' 2. Set TELEGRAM_BOT_TOKEN (get one from @BotFather on Telegram)'
177
+ puts ' 3. Enable Telegram in ~/.kodo/config.yml'
163
178
  puts " 4. Edit ~/.kodo/persona.md to customize Kodo's personality"
164
- puts " 5. Run: kodo start"
165
- puts ""
166
- puts " Supported LLM providers:"
167
- puts " Anthropic, OpenAI, Gemini, DeepSeek, Mistral, Ollama,"
168
- puts " OpenRouter, Perplexity, xAI, and any OpenAI-compatible API"
179
+ puts ' 5. Run: kodo start'
180
+ puts ''
181
+ puts ' Supported LLM providers:'
182
+ puts ' Anthropic, OpenAI, Gemini, DeepSeek, Mistral, Ollama,'
183
+ puts ' OpenRouter, Perplexity, xAI, and any OpenAI-compatible API'
169
184
  end
170
185
 
171
186
  def status
172
187
  puts "🥁 Kodo v#{VERSION}"
173
- puts ""
188
+ puts ''
174
189
  puts " Home: #{Kodo.home_dir}"
175
- puts " Config: #{File.exist?(Config.config_path) ? "" : ""} #{Config.config_path}"
176
- puts ""
190
+ puts " Config: #{File.exist?(Config.config_path) ? '' : ''} #{Config.config_path}"
191
+ puts ''
177
192
 
178
193
  # Prompt files
179
- puts " Prompt files:"
194
+ puts ' Prompt files:'
180
195
  %w[persona.md user.md pulse.md origin.md].each do |f|
181
196
  path = File.join(Kodo.home_dir, f)
182
- status = File.exist?(path) ? "" : " "
197
+ status = File.exist?(path) ? '' : ' '
183
198
  puts " #{status} #{f}"
184
199
  end
185
- puts ""
200
+ puts ''
186
201
 
187
202
  # Provider keys
188
- puts " LLM providers:"
203
+ puts ' LLM providers:'
189
204
  {
190
- "ANTHROPIC_API_KEY" => "Anthropic",
191
- "OPENAI_API_KEY" => "OpenAI",
192
- "GEMINI_API_KEY" => "Gemini",
193
- "OLLAMA_API_BASE" => "Ollama"
205
+ 'ANTHROPIC_API_KEY' => 'Anthropic',
206
+ 'OPENAI_API_KEY' => 'OpenAI',
207
+ 'GEMINI_API_KEY' => 'Gemini',
208
+ 'OLLAMA_API_BASE' => 'Ollama'
194
209
  }.each do |env, name|
195
- s = ENV[env] ? "✅ set" : " not set"
196
- puts " #{s.start_with?("") ? "" : " "} #{name.ljust(12)} (#{env})"
210
+ s = ENV[env] ? '✅ set' : ' not set'
211
+ puts " #{s.start_with?('') ? '' : ' '} #{name.ljust(12)} (#{env})"
212
+ end
213
+ puts ''
214
+
215
+ puts " Telegram: #{ENV['TELEGRAM_BOT_TOKEN'] ? '✅ set' : '❌ TELEGRAM_BOT_TOKEN missing'}"
216
+ end
217
+
218
+ def resolve_chat_search_provider(broker)
219
+ # Try broker first (handles both stored secrets and env vars)
220
+ if broker.available?('tavily_api_key')
221
+ api_key = broker.fetch('tavily_api_key', requestor: 'search')
222
+ return Search::Tavily.new(api_key: api_key) if api_key
197
223
  end
198
- puts ""
199
224
 
200
- puts " Telegram: #{ENV["TELEGRAM_BOT_TOKEN"] ? "✅ set" : "❌ TELEGRAM_BOT_TOKEN missing"}"
225
+ # Fall back to config-based resolution
226
+ Kodo.config.search_provider_instance
227
+ rescue Kodo::Error
228
+ nil
201
229
  end
202
230
 
203
231
  def with_spinner
204
- frames = ["", "", "", "", "", "", "", "", "", ""]
232
+ frames = ['', '', '', '', '', '', '', '', '', '']
205
233
  done = false
206
234
  spinner = Thread.new do
207
235
  i = 0
208
- while !done
236
+ until done
209
237
  print "\r\e[36m#{frames[i % frames.length]} thinking...\e[0m"
210
238
  i += 1
211
239
  sleep 0.1
@@ -221,21 +249,21 @@ module Kodo
221
249
 
222
250
  def help
223
251
  puts "🥁 Kodo v#{VERSION} — your personal AI agent"
224
- puts ""
225
- puts "Usage: kodo <command> [options]"
226
- puts ""
252
+ puts ''
253
+ puts 'Usage: kodo <command> [options]'
254
+ puts ''
227
255
  COMMANDS.each do |cmd, desc|
228
- puts " %-12s %s" % [cmd, desc]
256
+ puts format(' %-12s %s', cmd, desc)
229
257
  end
230
- puts ""
231
- puts "Options:"
232
- puts " --heartbeat-interval=N Set heartbeat interval in seconds (default: 60)"
233
- puts ""
234
- puts "Prompt files in ~/.kodo/:"
258
+ puts ''
259
+ puts 'Options:'
260
+ puts ' --heartbeat-interval=N Set heartbeat interval in seconds (default: 60)'
261
+ puts ''
262
+ puts 'Prompt files in ~/.kodo/:'
235
263
  puts " persona.md Your agent's personality and tone"
236
- puts " user.md Tell Kodo about yourself"
237
- puts " pulse.md What to notice during idle beats"
238
- puts " origin.md First-run onboarding conversation"
264
+ puts ' user.md Tell Kodo about yourself'
265
+ puts ' pulse.md What to notice during idle beats'
266
+ puts ' origin.md First-run onboarding conversation'
239
267
  end
240
268
  end
241
269
  end
data/config/default.yml CHANGED
@@ -35,6 +35,14 @@ channels:
35
35
  enabled: false
36
36
  bot_token_env: TELEGRAM_BOT_TOKEN
37
37
 
38
+ # Web search — enables web_search and fetch_url tools.
39
+ # Disabled by default. Set provider to enable.
40
+ # search:
41
+ # provider: tavily
42
+ # providers:
43
+ # tavily:
44
+ # api_key_env: TAVILY_API_KEY
45
+
38
46
  memory:
39
47
  encryption: false
40
48
  passphrase_env: KODO_PASSPHRASE
data/lib/kodo/config.rb CHANGED
@@ -1,48 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "fileutils"
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'securerandom'
5
6
 
6
7
  module Kodo
7
8
  class Config
8
9
  # Map of Kodo config key → RubyLLM config setter name
9
10
  PROVIDER_KEY_MAP = {
10
- "anthropic" => "anthropic",
11
- "openai" => "openai",
12
- "gemini" => "gemini",
13
- "deepseek" => "deepseek",
14
- "mistral" => "mistral",
15
- "openrouter" => "openrouter",
16
- "perplexity" => "perplexity",
17
- "xai" => "xai"
11
+ 'anthropic' => 'anthropic',
12
+ 'openai' => 'openai',
13
+ 'gemini' => 'gemini',
14
+ 'deepseek' => 'deepseek',
15
+ 'mistral' => 'mistral',
16
+ 'openrouter' => 'openrouter',
17
+ 'perplexity' => 'perplexity',
18
+ 'xai' => 'xai'
18
19
  }.freeze
19
20
 
20
21
  DEFAULTS = {
21
- "daemon" => {
22
- "port" => 7377,
23
- "heartbeat_interval" => 60
22
+ 'daemon' => {
23
+ 'port' => 7377,
24
+ 'heartbeat_interval' => 60
24
25
  },
25
- "llm" => {
26
- "model" => "claude-sonnet-4-6",
27
- "utility_model" => "claude-haiku-4-5-20251001",
28
- "providers" => {
29
- "anthropic" => { "api_key_env" => "ANTHROPIC_API_KEY" }
26
+ 'llm' => {
27
+ 'model' => 'claude-sonnet-4-6',
28
+ 'utility_model' => 'claude-haiku-4-5-20251001',
29
+ 'providers' => {
30
+ 'anthropic' => { 'api_key_env' => 'ANTHROPIC_API_KEY' }
30
31
  }
31
32
  },
32
- "channels" => {
33
- "telegram" => {
34
- "enabled" => false,
35
- "bot_token_env" => "TELEGRAM_BOT_TOKEN"
33
+ 'channels' => {
34
+ 'telegram' => {
35
+ 'enabled' => false,
36
+ 'bot_token_env' => 'TELEGRAM_BOT_TOKEN'
36
37
  }
37
38
  },
38
- "memory" => {
39
- "encryption" => false,
40
- "passphrase_env" => "KODO_PASSPHRASE",
41
- "store" => "file"
39
+ 'memory' => {
40
+ 'encryption' => false,
41
+ 'passphrase_env' => 'KODO_PASSPHRASE',
42
+ 'store' => 'file'
42
43
  },
43
- "logging" => {
44
- "level" => "info",
45
- "audit" => true
44
+ 'search' => {
45
+ 'provider' => nil,
46
+ 'providers' => {
47
+ 'tavily' => { 'api_key_env' => 'TAVILY_API_KEY' }
48
+ }
49
+ },
50
+ 'logging' => {
51
+ 'level' => 'info',
52
+ 'audit' => true
46
53
  }
47
54
  }.freeze
48
55
 
@@ -61,23 +68,23 @@ module Kodo
61
68
  end
62
69
 
63
70
  def config_path
64
- File.join(Kodo.home_dir, "config.yml")
71
+ File.join(Kodo.home_dir, 'config.yml')
65
72
  end
66
73
 
67
74
  def ensure_home_dir!
68
75
  dirs = [
69
76
  Kodo.home_dir,
70
- File.join(Kodo.home_dir, "memory", "conversations"),
71
- File.join(Kodo.home_dir, "memory", "knowledge"),
72
- File.join(Kodo.home_dir, "memory", "reminders"),
73
- File.join(Kodo.home_dir, "memory", "audit"),
74
- File.join(Kodo.home_dir, "skills")
77
+ File.join(Kodo.home_dir, 'memory', 'conversations'),
78
+ File.join(Kodo.home_dir, 'memory', 'knowledge'),
79
+ File.join(Kodo.home_dir, 'memory', 'reminders'),
80
+ File.join(Kodo.home_dir, 'memory', 'audit'),
81
+ File.join(Kodo.home_dir, 'skills')
75
82
  ]
76
83
  dirs.each { |d| FileUtils.mkdir_p(d) }
77
84
 
78
- unless File.exist?(config_path)
79
- File.write(config_path, YAML.dump(DEFAULTS))
80
- end
85
+ return if File.exist?(config_path)
86
+
87
+ File.write(config_path, YAML.dump(DEFAULTS))
81
88
  end
82
89
 
83
90
  private
@@ -94,20 +101,20 @@ module Kodo
94
101
  end
95
102
 
96
103
  # --- Daemon ---
97
- def port = data.dig("daemon", "port")
98
- def heartbeat_interval = data.dig("daemon", "heartbeat_interval")
104
+ def port = data.dig('daemon', 'port')
105
+ def heartbeat_interval = data.dig('daemon', 'heartbeat_interval')
99
106
 
100
107
  # --- LLM ---
101
- def llm_model = data.dig("llm", "model")
102
- def utility_model = data.dig("llm", "utility_model") || llm_model
108
+ def llm_model = data.dig('llm', 'model')
109
+ def utility_model = data.dig('llm', 'utility_model') || llm_model
103
110
 
104
111
  # Returns a hash of { "provider_name" => "actual_api_key" } for all configured providers
105
112
  def llm_api_keys
106
- providers = data.dig("llm", "providers") || {}
113
+ providers = data.dig('llm', 'providers') || {}
107
114
  keys = {}
108
115
 
109
116
  providers.each do |provider, settings|
110
- env_var = settings["api_key_env"]
117
+ env_var = settings['api_key_env']
111
118
  next unless env_var
112
119
 
113
120
  key = ENV[env_var]
@@ -117,23 +124,21 @@ module Kodo
117
124
  end
118
125
  end
119
126
 
120
- if keys.empty?
121
- raise Error, "No LLM API keys found. Set at least one provider key (e.g. ANTHROPIC_API_KEY)"
122
- end
127
+ raise Error, 'No LLM API keys found. Set at least one provider key (e.g. ANTHROPIC_API_KEY)' if keys.empty?
123
128
 
124
129
  keys
125
130
  end
126
131
 
127
132
  # Optional: Ollama base URL for local models
128
133
  def ollama_api_base
129
- data.dig("llm", "providers", "ollama", "api_base") || ENV["OLLAMA_API_BASE"]
134
+ data.dig('llm', 'providers', 'ollama', 'api_base') || ENV['OLLAMA_API_BASE']
130
135
  end
131
136
 
132
137
  # --- Memory / Encryption ---
133
- def memory_encryption? = data.dig("memory", "encryption") == true
138
+ def memory_encryption? = data.dig('memory', 'encryption') == true
134
139
 
135
140
  def memory_passphrase_env
136
- data.dig("memory", "passphrase_env") || "KODO_PASSPHRASE"
141
+ data.dig('memory', 'passphrase_env') || 'KODO_PASSPHRASE'
137
142
  end
138
143
 
139
144
  def memory_passphrase
@@ -141,21 +146,72 @@ module Kodo
141
146
  if memory_encryption? && (passphrase.nil? || passphrase.empty?)
142
147
  raise Error, "Memory encryption is enabled but #{memory_passphrase_env} is not set"
143
148
  end
149
+
144
150
  passphrase
145
151
  end
146
152
 
153
+ # --- Secrets ---
154
+ def secrets_passphrase
155
+ passphrase_path = File.join(Kodo.home_dir, '.passphrase')
156
+ if File.exist?(passphrase_path)
157
+ File.read(passphrase_path).strip
158
+ else
159
+ passphrase = SecureRandom.hex(32)
160
+ FileUtils.mkdir_p(Kodo.home_dir)
161
+ File.write(passphrase_path, passphrase)
162
+ File.chmod(0o600, passphrase_path)
163
+ passphrase
164
+ end
165
+ end
166
+
147
167
  # --- Logging ---
148
- def log_level = data.dig("logging", "level")&.to_sym || :info
149
- def audit_enabled? = data.dig("logging", "audit") != false
168
+ def log_level = data.dig('logging', 'level')&.to_sym || :info
169
+ def audit_enabled? = data.dig('logging', 'audit') != false
150
170
 
151
171
  # --- Channels ---
152
172
  def telegram_bot_token
153
- env_var = data.dig("channels", "telegram", "bot_token_env")
173
+ env_var = data.dig('channels', 'telegram', 'bot_token_env')
154
174
  ENV.fetch(env_var) { raise Error, "Missing environment variable: #{env_var}" }
155
175
  end
156
176
 
157
177
  def telegram_enabled?
158
- data.dig("channels", "telegram", "enabled") == true
178
+ data.dig('channels', 'telegram', 'enabled') == true
179
+ end
180
+
181
+ # --- Search ---
182
+ def search_provider
183
+ data.dig('search', 'provider')
184
+ end
185
+
186
+ def search_configured?
187
+ !search_provider.nil? && !search_provider.empty?
188
+ end
189
+
190
+ def search_api_key
191
+ return nil unless search_configured?
192
+
193
+ provider = search_provider
194
+ env_var = data.dig('search', 'providers', provider, 'api_key_env')
195
+ return nil unless env_var
196
+
197
+ ENV[env_var]
198
+ end
199
+
200
+ def search_provider_instance
201
+ return nil unless search_configured?
202
+
203
+ case search_provider
204
+ when 'tavily'
205
+ api_key = search_api_key
206
+ unless api_key
207
+ raise Error,
208
+ "Tavily API key not set (#{data.dig('search', 'providers', 'tavily', 'api_key_env')})"
209
+ end
210
+
211
+ Search::Tavily.new(api_key: api_key)
212
+ else
213
+ raise Error, "Unknown search provider: #{search_provider}"
214
+ end
159
215
  end
160
216
  end
161
217
  end