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 +4 -4
- data/bin/kodo +113 -85
- data/config/default.yml +8 -0
- data/lib/kodo/config.rb +110 -54
- data/lib/kodo/daemon.rb +44 -14
- data/lib/kodo/llm.rb +34 -6
- data/lib/kodo/prompt_assembler.rb +59 -32
- data/lib/kodo/router.rb +72 -7
- data/lib/kodo/search/base.rb +11 -0
- data/lib/kodo/search/result.rb +11 -0
- data/lib/kodo/search/tavily.rb +60 -0
- data/lib/kodo/secrets/broker.rb +93 -0
- data/lib/kodo/secrets/grant.rb +7 -0
- data/lib/kodo/secrets/store.rb +72 -0
- data/lib/kodo/tools/dismiss_reminder.rb +4 -0
- data/lib/kodo/tools/fetch_url.rb +185 -0
- data/lib/kodo/tools/forget_fact.rb +4 -0
- data/lib/kodo/tools/list_reminders.rb +4 -0
- data/lib/kodo/tools/prompt_contributor.rb +50 -0
- data/lib/kodo/tools/recall_facts.rb +4 -0
- data/lib/kodo/tools/remember_fact.rb +6 -0
- data/lib/kodo/tools/set_reminder.rb +6 -0
- data/lib/kodo/tools/store_secret.rb +146 -0
- data/lib/kodo/tools/update_fact.rb +4 -0
- data/lib/kodo/tools/web_search.rb +82 -0
- data/lib/kodo/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1b635240737c255adbfeae59a3a49414133f0b5a01897e92f44628334fe0b00
|
|
4
|
+
data.tar.gz: 04731bbe6a6419939afb2851c44ff328556a5ac3e2f22c8f9918a3a9e92a93b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
4
|
+
require_relative '../lib/kodo'
|
|
5
5
|
|
|
6
6
|
module Kodo
|
|
7
7
|
class CLI
|
|
8
8
|
COMMANDS = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 ||
|
|
20
|
+
command = args.first || 'help'
|
|
21
21
|
|
|
22
22
|
case command
|
|
23
|
-
when
|
|
24
|
-
when
|
|
25
|
-
when
|
|
26
|
-
when
|
|
27
|
-
when
|
|
28
|
-
when
|
|
29
|
-
when
|
|
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?(
|
|
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(
|
|
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:
|
|
98
|
+
channel_id: 'console',
|
|
84
99
|
sender: :user,
|
|
85
100
|
content: input,
|
|
86
|
-
metadata: { chat_id:
|
|
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
|
|
104
|
-
puts
|
|
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[
|
|
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[
|
|
145
|
+
active = reminder_store.all_active.sort_by { |r| r['due_at'] }
|
|
131
146
|
if active.empty?
|
|
132
|
-
puts
|
|
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
|
|
153
|
-
puts
|
|
154
|
-
puts
|
|
155
|
-
puts
|
|
156
|
-
puts
|
|
157
|
-
puts
|
|
158
|
-
puts
|
|
159
|
-
puts
|
|
160
|
-
puts
|
|
161
|
-
puts
|
|
162
|
-
puts
|
|
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
|
|
165
|
-
puts
|
|
166
|
-
puts
|
|
167
|
-
puts
|
|
168
|
-
puts
|
|
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) ?
|
|
176
|
-
puts
|
|
190
|
+
puts " Config: #{File.exist?(Config.config_path) ? '✅' : '❌'} #{Config.config_path}"
|
|
191
|
+
puts ''
|
|
177
192
|
|
|
178
193
|
# Prompt files
|
|
179
|
-
puts
|
|
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
|
|
203
|
+
puts ' LLM providers:'
|
|
189
204
|
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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] ?
|
|
196
|
-
puts " #{s.start_with?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
226
|
-
puts
|
|
252
|
+
puts ''
|
|
253
|
+
puts 'Usage: kodo <command> [options]'
|
|
254
|
+
puts ''
|
|
227
255
|
COMMANDS.each do |cmd, desc|
|
|
228
|
-
puts
|
|
256
|
+
puts format(' %-12s %s', cmd, desc)
|
|
229
257
|
end
|
|
230
|
-
puts
|
|
231
|
-
puts
|
|
232
|
-
puts
|
|
233
|
-
puts
|
|
234
|
-
puts
|
|
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
|
|
237
|
-
puts
|
|
238
|
-
puts
|
|
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
|
|
4
|
-
require
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
'daemon' => {
|
|
23
|
+
'port' => 7377,
|
|
24
|
+
'heartbeat_interval' => 60
|
|
24
25
|
},
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
'channels' => {
|
|
34
|
+
'telegram' => {
|
|
35
|
+
'enabled' => false,
|
|
36
|
+
'bot_token_env' => 'TELEGRAM_BOT_TOKEN'
|
|
36
37
|
}
|
|
37
38
|
},
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
'memory' => {
|
|
40
|
+
'encryption' => false,
|
|
41
|
+
'passphrase_env' => 'KODO_PASSPHRASE',
|
|
42
|
+
'store' => 'file'
|
|
42
43
|
},
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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,
|
|
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,
|
|
71
|
-
File.join(Kodo.home_dir,
|
|
72
|
-
File.join(Kodo.home_dir,
|
|
73
|
-
File.join(Kodo.home_dir,
|
|
74
|
-
File.join(Kodo.home_dir,
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
98
|
-
def heartbeat_interval = data.dig(
|
|
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(
|
|
102
|
-
def utility_model = data.dig(
|
|
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(
|
|
113
|
+
providers = data.dig('llm', 'providers') || {}
|
|
107
114
|
keys = {}
|
|
108
115
|
|
|
109
116
|
providers.each do |provider, settings|
|
|
110
|
-
env_var = settings[
|
|
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(
|
|
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(
|
|
138
|
+
def memory_encryption? = data.dig('memory', 'encryption') == true
|
|
134
139
|
|
|
135
140
|
def memory_passphrase_env
|
|
136
|
-
data.dig(
|
|
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(
|
|
149
|
-
def audit_enabled? = data.dig(
|
|
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(
|
|
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(
|
|
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
|