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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Kodo
|
|
7
|
+
module Secrets
|
|
8
|
+
class Store
|
|
9
|
+
def initialize(passphrase:, secrets_dir: nil)
|
|
10
|
+
@secrets_dir = secrets_dir || Kodo.home_dir
|
|
11
|
+
@passphrase = passphrase
|
|
12
|
+
FileUtils.mkdir_p(@secrets_dir)
|
|
13
|
+
@secrets = load_secrets
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def put(name, value, source: 'user', validated: false)
|
|
17
|
+
@secrets[name] = {
|
|
18
|
+
'value' => value,
|
|
19
|
+
'source' => source,
|
|
20
|
+
'validated' => validated,
|
|
21
|
+
'stored_at' => Time.now.iso8601
|
|
22
|
+
}
|
|
23
|
+
save_secrets
|
|
24
|
+
@secrets[name]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get(name)
|
|
28
|
+
entry = @secrets[name]
|
|
29
|
+
entry&.fetch('value', nil)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def exists?(name)
|
|
33
|
+
@secrets.key?(name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(name)
|
|
37
|
+
removed = @secrets.delete(name)
|
|
38
|
+
save_secrets if removed
|
|
39
|
+
!removed.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def names
|
|
43
|
+
@secrets.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def secrets_path
|
|
49
|
+
File.join(@secrets_dir, 'secrets.enc')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load_secrets
|
|
53
|
+
path = secrets_path
|
|
54
|
+
return {} unless File.exist?(path)
|
|
55
|
+
|
|
56
|
+
raw = File.binread(path)
|
|
57
|
+
|
|
58
|
+
raw = Memory::Encryption.decrypt(raw, key: @passphrase) if Memory::Encryption.encrypted?(raw)
|
|
59
|
+
|
|
60
|
+
JSON.parse(raw)
|
|
61
|
+
rescue JSON::ParserError => e
|
|
62
|
+
Kodo.logger.warn("Corrupt secrets file #{path}: #{e.message}")
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def save_secrets
|
|
67
|
+
json = JSON.generate(@secrets)
|
|
68
|
+
File.binwrite(secrets_path, Memory::Encryption.encrypt(json, key: @passphrase))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -5,6 +5,10 @@ require "ruby_llm"
|
|
|
5
5
|
module Kodo
|
|
6
6
|
module Tools
|
|
7
7
|
class DismissReminder < RubyLLM::Tool
|
|
8
|
+
extend PromptContributor
|
|
9
|
+
|
|
10
|
+
capability_name 'Reminders'
|
|
11
|
+
|
|
8
12
|
description "Dismiss (cancel) an active reminder by its ID."
|
|
9
13
|
|
|
10
14
|
param :id, desc: "The UUID of the reminder to dismiss"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'resolv'
|
|
7
|
+
require 'ipaddr'
|
|
8
|
+
|
|
9
|
+
module Kodo
|
|
10
|
+
module Tools
|
|
11
|
+
class FetchUrl < RubyLLM::Tool
|
|
12
|
+
extend PromptContributor
|
|
13
|
+
|
|
14
|
+
capability_name 'Web Search'
|
|
15
|
+
|
|
16
|
+
MAX_PER_TURN = 3
|
|
17
|
+
MAX_CONTENT_LENGTH = 50_000
|
|
18
|
+
MAX_REDIRECTS = 5
|
|
19
|
+
READ_TIMEOUT = 15
|
|
20
|
+
OPEN_TIMEOUT = 10
|
|
21
|
+
USER_AGENT = "Kodo/#{VERSION} (bot; +https://kodo.bot)".freeze
|
|
22
|
+
|
|
23
|
+
# RFC 1918, loopback, link-local
|
|
24
|
+
BLOCKED_RANGES = [
|
|
25
|
+
IPAddr.new('10.0.0.0/8'),
|
|
26
|
+
IPAddr.new('172.16.0.0/12'),
|
|
27
|
+
IPAddr.new('192.168.0.0/16'),
|
|
28
|
+
IPAddr.new('127.0.0.0/8'),
|
|
29
|
+
IPAddr.new('169.254.0.0/16'),
|
|
30
|
+
IPAddr.new('0.0.0.0/8'),
|
|
31
|
+
IPAddr.new('::1/128'),
|
|
32
|
+
IPAddr.new('fc00::/7'),
|
|
33
|
+
IPAddr.new('fe80::/10')
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
description 'Fetch and read the contents of a web page. Use this to read articles, ' \
|
|
37
|
+
'documentation, or any publicly accessible URL the user provides.'
|
|
38
|
+
|
|
39
|
+
param :url, desc: 'The URL to fetch (http or https only)'
|
|
40
|
+
|
|
41
|
+
def initialize(audit:)
|
|
42
|
+
super()
|
|
43
|
+
@audit = audit
|
|
44
|
+
@turn_count = 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset_turn_count!
|
|
48
|
+
@turn_count = 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def execute(url:)
|
|
52
|
+
@turn_count += 1
|
|
53
|
+
if @turn_count > MAX_PER_TURN
|
|
54
|
+
return "Rate limit reached (max #{MAX_PER_TURN} fetches per message). Try again next message."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
uri = validate_url(url)
|
|
58
|
+
return uri if uri.is_a?(String) # error message
|
|
59
|
+
|
|
60
|
+
content = fetch_with_redirects(uri)
|
|
61
|
+
return content if content.is_a?(String) && content.start_with?('Error:')
|
|
62
|
+
|
|
63
|
+
text = extract_text(content)
|
|
64
|
+
text = text[0...MAX_CONTENT_LENGTH] if text.length > MAX_CONTENT_LENGTH
|
|
65
|
+
|
|
66
|
+
@audit.log(
|
|
67
|
+
event: 'url_fetched',
|
|
68
|
+
detail: "url:#{url} len:#{text.length}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
text.empty? ? "No readable content found at #{url}" : text
|
|
72
|
+
rescue Kodo::Error => e
|
|
73
|
+
e.message
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def name
|
|
77
|
+
'fetch_url'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def validate_url(url)
|
|
83
|
+
uri = URI.parse(url)
|
|
84
|
+
return 'Error: Only http and https URLs are supported.' unless %w[http https].include?(uri.scheme)
|
|
85
|
+
|
|
86
|
+
check_ssrf!(uri.host)
|
|
87
|
+
uri
|
|
88
|
+
rescue URI::InvalidURIError
|
|
89
|
+
'Error: Invalid URL format.'
|
|
90
|
+
rescue Kodo::Error => e
|
|
91
|
+
"Error: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_ssrf!(hostname)
|
|
95
|
+
addresses = Resolv.getaddresses(hostname)
|
|
96
|
+
|
|
97
|
+
raise Kodo::Error, "Could not resolve hostname: #{hostname}" if addresses.empty?
|
|
98
|
+
|
|
99
|
+
addresses.each do |addr|
|
|
100
|
+
ip = IPAddr.new(addr)
|
|
101
|
+
if BLOCKED_RANGES.any? { |range| range.include?(ip) }
|
|
102
|
+
raise Kodo::Error, 'Access to private/internal network addresses is not allowed.'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fetch_with_redirects(uri, redirects_remaining = MAX_REDIRECTS)
|
|
108
|
+
check_ssrf!(uri.host)
|
|
109
|
+
|
|
110
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
111
|
+
http.use_ssl = uri.scheme == 'https'
|
|
112
|
+
http.read_timeout = READ_TIMEOUT
|
|
113
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
114
|
+
|
|
115
|
+
request = Net::HTTP::Get.new(uri)
|
|
116
|
+
request['User-Agent'] = USER_AGENT
|
|
117
|
+
|
|
118
|
+
response = http.request(request)
|
|
119
|
+
|
|
120
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
121
|
+
response.body || ''
|
|
122
|
+
elsif response.is_a?(Net::HTTPRedirection)
|
|
123
|
+
return 'Error: Too many redirects.' if redirects_remaining <= 0
|
|
124
|
+
|
|
125
|
+
location = response['location']
|
|
126
|
+
return 'Error: Redirect with no location header.' unless location
|
|
127
|
+
|
|
128
|
+
redirect_uri = URI.parse(location)
|
|
129
|
+
# Handle relative redirects
|
|
130
|
+
redirect_uri = uri + location unless redirect_uri.host
|
|
131
|
+
|
|
132
|
+
return 'Error: Redirect to non-HTTP scheme.' unless %w[http https].include?(redirect_uri.scheme)
|
|
133
|
+
|
|
134
|
+
fetch_with_redirects(redirect_uri, redirects_remaining - 1)
|
|
135
|
+
else
|
|
136
|
+
"Error: HTTP #{response.code} #{response.message}"
|
|
137
|
+
end
|
|
138
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
139
|
+
'Error: Request timed out.'
|
|
140
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
141
|
+
"Error: Connection failed: #{e.message}"
|
|
142
|
+
rescue Kodo::Error => e
|
|
143
|
+
"Error: #{e.message}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract_text(html)
|
|
147
|
+
return '' if html.nil? || html.empty?
|
|
148
|
+
|
|
149
|
+
text = html.dup
|
|
150
|
+
|
|
151
|
+
# Remove script and style blocks
|
|
152
|
+
text.gsub!(%r{<script[^>]*>.*?</script>}mi, '')
|
|
153
|
+
text.gsub!(%r{<style[^>]*>.*?</style>}mi, '')
|
|
154
|
+
|
|
155
|
+
# Remove HTML comments
|
|
156
|
+
text.gsub!(/<!--.*?-->/m, '')
|
|
157
|
+
|
|
158
|
+
# Convert block-level tags to newlines
|
|
159
|
+
text.gsub!(%r{<(?:br|p|div|h[1-6]|li|tr|blockquote|hr)[^>]*/?>}i, "\n")
|
|
160
|
+
text.gsub!(%r{</(?:p|div|h[1-6]|li|tr|blockquote|table|ul|ol)>}i, "\n")
|
|
161
|
+
|
|
162
|
+
# Strip remaining tags
|
|
163
|
+
text.gsub!(/<[^>]+>/, '')
|
|
164
|
+
|
|
165
|
+
# Decode common HTML entities
|
|
166
|
+
text.gsub!('&', '&')
|
|
167
|
+
text.gsub!('<', '<')
|
|
168
|
+
text.gsub!('>', '>')
|
|
169
|
+
text.gsub!('"', '"')
|
|
170
|
+
text.gsub!(''', "'")
|
|
171
|
+
text.gsub!(''', "'")
|
|
172
|
+
text.gsub!(' ', ' ')
|
|
173
|
+
|
|
174
|
+
# Normalize whitespace
|
|
175
|
+
text.gsub!(/[ \t]+/, ' ')
|
|
176
|
+
text.gsub!(/\n[ \t]+/, "\n")
|
|
177
|
+
text.gsub!(/[ \t]+\n/, "\n")
|
|
178
|
+
text.gsub!(/\n{3,}/, "\n\n")
|
|
179
|
+
text.strip!
|
|
180
|
+
|
|
181
|
+
text
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -5,6 +5,10 @@ require "ruby_llm"
|
|
|
5
5
|
module Kodo
|
|
6
6
|
module Tools
|
|
7
7
|
class ForgetFact < RubyLLM::Tool
|
|
8
|
+
extend PromptContributor
|
|
9
|
+
|
|
10
|
+
capability_name 'Knowledge'
|
|
11
|
+
|
|
8
12
|
description "Forget a previously remembered fact. Use this when the user asks you " \
|
|
9
13
|
"to forget something or when information is outdated."
|
|
10
14
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kodo
|
|
4
|
+
module Tools
|
|
5
|
+
# Mixin for tool classes to declare prompt-level capability metadata.
|
|
6
|
+
# Tools `extend` this module and use getter/setter class methods to
|
|
7
|
+
# describe how they appear in the assembled system prompt.
|
|
8
|
+
#
|
|
9
|
+
# class WebSearch < RubyLLM::Tool
|
|
10
|
+
# extend PromptContributor
|
|
11
|
+
# capability_name "Web Search"
|
|
12
|
+
# capability_primary true
|
|
13
|
+
# enabled_guidance "Search the web for current information."
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
module PromptContributor
|
|
17
|
+
def capability_name(name = :__unset__)
|
|
18
|
+
if name == :__unset__
|
|
19
|
+
@capability_name
|
|
20
|
+
else
|
|
21
|
+
@capability_name = name
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def capability_primary(val = :__unset__)
|
|
26
|
+
if val == :__unset__
|
|
27
|
+
@capability_primary || false
|
|
28
|
+
else
|
|
29
|
+
@capability_primary = val
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enabled_guidance(text = :__unset__)
|
|
34
|
+
if text == :__unset__
|
|
35
|
+
@enabled_guidance
|
|
36
|
+
else
|
|
37
|
+
@enabled_guidance = text
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def disabled_guidance(text = :__unset__)
|
|
42
|
+
if text == :__unset__
|
|
43
|
+
@disabled_guidance
|
|
44
|
+
else
|
|
45
|
+
@disabled_guidance = text
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -5,6 +5,10 @@ require "ruby_llm"
|
|
|
5
5
|
module Kodo
|
|
6
6
|
module Tools
|
|
7
7
|
class RecallFacts < RubyLLM::Tool
|
|
8
|
+
extend PromptContributor
|
|
9
|
+
|
|
10
|
+
capability_name 'Knowledge'
|
|
11
|
+
|
|
8
12
|
description "Search your knowledge store for facts about the user. " \
|
|
9
13
|
"Use this when you need to look up specific information, especially " \
|
|
10
14
|
"if the knowledge was truncated in your system prompt."
|
|
@@ -5,6 +5,12 @@ require "ruby_llm"
|
|
|
5
5
|
module Kodo
|
|
6
6
|
module Tools
|
|
7
7
|
class RememberFact < RubyLLM::Tool
|
|
8
|
+
extend PromptContributor
|
|
9
|
+
|
|
10
|
+
capability_name 'Knowledge'
|
|
11
|
+
capability_primary true
|
|
12
|
+
enabled_guidance 'Remember, recall, update, and forget facts about the user across sessions.'
|
|
13
|
+
|
|
8
14
|
MAX_PER_TURN = 5
|
|
9
15
|
MAX_CONTENT_LENGTH = 500
|
|
10
16
|
|
|
@@ -6,6 +6,12 @@ require "time"
|
|
|
6
6
|
module Kodo
|
|
7
7
|
module Tools
|
|
8
8
|
class SetReminder < RubyLLM::Tool
|
|
9
|
+
extend PromptContributor
|
|
10
|
+
|
|
11
|
+
capability_name 'Reminders'
|
|
12
|
+
capability_primary true
|
|
13
|
+
enabled_guidance 'Set, list, and dismiss reminders. Reminders are delivered proactively when due.'
|
|
14
|
+
|
|
9
15
|
MAX_PER_TURN = 3
|
|
10
16
|
MAX_CONTENT_LENGTH = 500
|
|
11
17
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module Kodo
|
|
9
|
+
module Tools
|
|
10
|
+
class StoreSecret < RubyLLM::Tool
|
|
11
|
+
extend PromptContributor
|
|
12
|
+
|
|
13
|
+
capability_name 'Secret Storage'
|
|
14
|
+
capability_primary true
|
|
15
|
+
enabled_guidance 'Use store_secret to store and activate API keys securely — no restart required.'
|
|
16
|
+
|
|
17
|
+
MAX_PER_TURN = 2
|
|
18
|
+
VALIDATION_TIMEOUT = 5
|
|
19
|
+
|
|
20
|
+
KNOWN_SECRETS = {
|
|
21
|
+
'anthropic_api_key' => { description: 'Anthropic API key', prefix: 'sk-ant-' },
|
|
22
|
+
'openai_api_key' => { description: 'OpenAI API key', prefix: 'sk-' },
|
|
23
|
+
'gemini_api_key' => { description: 'Google Gemini API key' },
|
|
24
|
+
'deepseek_api_key' => { description: 'DeepSeek API key' },
|
|
25
|
+
'mistral_api_key' => { description: 'Mistral API key' },
|
|
26
|
+
'openrouter_api_key' => { description: 'OpenRouter API key', prefix: 'sk-or-' },
|
|
27
|
+
'perplexity_api_key' => { description: 'Perplexity API key' },
|
|
28
|
+
'xai_api_key' => { description: 'xAI API key' },
|
|
29
|
+
'tavily_api_key' => { description: 'Tavily API key', prefix: 'tvly-' },
|
|
30
|
+
'telegram_bot_token' => { description: 'Telegram bot token' }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
description 'Securely store an API key or token. Use this when the user provides ' \
|
|
34
|
+
'a key they want to configure. The key is encrypted at rest and ' \
|
|
35
|
+
'activated immediately without requiring a restart.'
|
|
36
|
+
|
|
37
|
+
param :secret_name, desc: "The secret identifier. One of: #{KNOWN_SECRETS.keys.join(', ')}"
|
|
38
|
+
param :secret_value, desc: 'The secret value (API key or token)'
|
|
39
|
+
|
|
40
|
+
def initialize(broker:, audit:, on_secret_stored: nil)
|
|
41
|
+
super()
|
|
42
|
+
@broker = broker
|
|
43
|
+
@audit = audit
|
|
44
|
+
@on_secret_stored = on_secret_stored
|
|
45
|
+
@turn_count = 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset_turn_count!
|
|
49
|
+
@turn_count = 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def execute(secret_name:, secret_value:) # rubocop:disable Metrics/MethodLength
|
|
53
|
+
unless KNOWN_SECRETS.key?(secret_name)
|
|
54
|
+
return "Unknown secret '#{secret_name}'. Known secrets: #{KNOWN_SECRETS.keys.join(', ')}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@turn_count += 1
|
|
58
|
+
if @turn_count > MAX_PER_TURN
|
|
59
|
+
return "Rate limit reached (max #{MAX_PER_TURN} secrets per message). Try again next message."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
prefix_error = validate_prefix(secret_name, secret_value)
|
|
63
|
+
return prefix_error if prefix_error
|
|
64
|
+
|
|
65
|
+
validated = validate_key(secret_name, secret_value)
|
|
66
|
+
|
|
67
|
+
@broker.store(secret_name, secret_value, source: 'chat', validated: validated)
|
|
68
|
+
|
|
69
|
+
@audit.log(
|
|
70
|
+
event: 'secret_stored_via_tool',
|
|
71
|
+
detail: "secret:#{secret_name} validated:#{validated}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@on_secret_stored&.call(secret_name)
|
|
75
|
+
|
|
76
|
+
desc = KNOWN_SECRETS[secret_name][:description]
|
|
77
|
+
if validated
|
|
78
|
+
"#{desc} stored and validated successfully. It's now active — no restart needed."
|
|
79
|
+
else
|
|
80
|
+
"#{desc} stored and activated. Couldn't verify it online, but it's ready to use."
|
|
81
|
+
end
|
|
82
|
+
rescue Kodo::Error => e
|
|
83
|
+
e.message
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def name
|
|
87
|
+
'store_secret'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def validate_prefix(secret_name, value)
|
|
93
|
+
expected = KNOWN_SECRETS.dig(secret_name, :prefix)
|
|
94
|
+
return nil unless expected
|
|
95
|
+
|
|
96
|
+
return nil if value.start_with?(expected)
|
|
97
|
+
|
|
98
|
+
"Invalid #{KNOWN_SECRETS[secret_name][:description]}: expected to start with '#{expected}'"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_key(secret_name, value)
|
|
102
|
+
case secret_name
|
|
103
|
+
when 'tavily_api_key' then validate_tavily(value)
|
|
104
|
+
when 'anthropic_api_key' then validate_anthropic(value)
|
|
105
|
+
else false
|
|
106
|
+
end
|
|
107
|
+
rescue StandardError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_tavily(key)
|
|
112
|
+
uri = URI('https://api.tavily.com/search')
|
|
113
|
+
body = { api_key: key, query: 'test', max_results: 1 }.to_json
|
|
114
|
+
response = http_post(uri, body, 'application/json')
|
|
115
|
+
response.is_a?(Net::HTTPSuccess)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_anthropic(key)
|
|
119
|
+
uri = URI('https://api.anthropic.com/v1/messages')
|
|
120
|
+
body = {
|
|
121
|
+
model: 'claude-haiku-4-5-20251001',
|
|
122
|
+
max_tokens: 1,
|
|
123
|
+
messages: [{ role: 'user', content: 'hi' }]
|
|
124
|
+
}.to_json
|
|
125
|
+
response = http_post(uri, body, 'application/json',
|
|
126
|
+
'x-api-key' => key,
|
|
127
|
+
'anthropic-version' => '2023-06-01')
|
|
128
|
+
response.is_a?(Net::HTTPSuccess)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def http_post(uri, body, content_type, extra_headers = {})
|
|
132
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
133
|
+
http.use_ssl = true
|
|
134
|
+
http.open_timeout = VALIDATION_TIMEOUT
|
|
135
|
+
http.read_timeout = VALIDATION_TIMEOUT
|
|
136
|
+
|
|
137
|
+
request = Net::HTTP::Post.new(uri)
|
|
138
|
+
request['Content-Type'] = content_type
|
|
139
|
+
extra_headers.each { |k, v| request[k] = v }
|
|
140
|
+
request.body = body
|
|
141
|
+
|
|
142
|
+
http.request(request)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
|
|
5
|
+
module Kodo
|
|
6
|
+
module Tools
|
|
7
|
+
class WebSearch < RubyLLM::Tool
|
|
8
|
+
extend PromptContributor
|
|
9
|
+
|
|
10
|
+
capability_name 'Web Search'
|
|
11
|
+
capability_primary true
|
|
12
|
+
enabled_guidance 'Search the web for current information.'
|
|
13
|
+
disabled_guidance \
|
|
14
|
+
"Tavily is the easiest option (free tier, 1000 searches/month, no credit card).\n" \
|
|
15
|
+
"Get an API key from https://app.tavily.com/sign-in\n" \
|
|
16
|
+
"Set the environment variable: export TAVILY_API_KEY=\"tvly-...\"\n" \
|
|
17
|
+
"Add to ~/.kodo/config.yml: search: { provider: tavily }\n" \
|
|
18
|
+
"Then restart Kodo.\n\n" \
|
|
19
|
+
"IMPORTANT: If the user pastes an API key into chat, remind them that credentials " \
|
|
20
|
+
"should be set as environment variables, not shared in conversation. The key will " \
|
|
21
|
+
"be redacted from conversation history for security."
|
|
22
|
+
|
|
23
|
+
DISABLED_GUIDANCE_WITH_SECRET_STORAGE =
|
|
24
|
+
"Tavily is the easiest option (free tier, 1000 searches/month, no credit card).\n" \
|
|
25
|
+
"Get an API key from https://app.tavily.com/sign-in\n" \
|
|
26
|
+
"They can paste the key right here in chat and you will store it securely."
|
|
27
|
+
|
|
28
|
+
MAX_PER_TURN = 3
|
|
29
|
+
|
|
30
|
+
description 'Search the web for current information. Use this when the user asks about ' \
|
|
31
|
+
"recent events, needs up-to-date facts, or wants information you don't have."
|
|
32
|
+
|
|
33
|
+
param :query, desc: 'The search query'
|
|
34
|
+
param :max_results, desc: 'Number of results to return (1-10, default 5)', required: false
|
|
35
|
+
|
|
36
|
+
def initialize(search_provider:, audit:)
|
|
37
|
+
super()
|
|
38
|
+
@search_provider = search_provider
|
|
39
|
+
@audit = audit
|
|
40
|
+
@turn_count = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset_turn_count!
|
|
44
|
+
@turn_count = 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def execute(query:, max_results: '5')
|
|
48
|
+
max_results = max_results.to_i.clamp(1, 10)
|
|
49
|
+
|
|
50
|
+
@turn_count += 1
|
|
51
|
+
if @turn_count > MAX_PER_TURN
|
|
52
|
+
return "Rate limit reached (max #{MAX_PER_TURN} searches per message). Try again next message."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
results = @search_provider.search(query, max_results: max_results)
|
|
56
|
+
|
|
57
|
+
@audit.log(
|
|
58
|
+
event: 'web_search',
|
|
59
|
+
detail: "query:#{query} results:#{results.length}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return "No results found for: #{query}" if results.empty?
|
|
63
|
+
|
|
64
|
+
format_results(results)
|
|
65
|
+
rescue Kodo::Error => e
|
|
66
|
+
e.message
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def name
|
|
70
|
+
'web_search'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def format_results(results)
|
|
76
|
+
results.each_with_index.map do |r, i|
|
|
77
|
+
"#{i + 1}. #{r.title}\n #{r.url}\n #{r.snippet}"
|
|
78
|
+
end.join("\n\n")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/kodo/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kodo-bot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Freedom Dumlao
|
|
@@ -66,14 +66,24 @@ files:
|
|
|
66
66
|
- lib/kodo/message.rb
|
|
67
67
|
- lib/kodo/prompt_assembler.rb
|
|
68
68
|
- lib/kodo/router.rb
|
|
69
|
+
- lib/kodo/search/base.rb
|
|
70
|
+
- lib/kodo/search/result.rb
|
|
71
|
+
- lib/kodo/search/tavily.rb
|
|
72
|
+
- lib/kodo/secrets/broker.rb
|
|
73
|
+
- lib/kodo/secrets/grant.rb
|
|
74
|
+
- lib/kodo/secrets/store.rb
|
|
69
75
|
- lib/kodo/tools/dismiss_reminder.rb
|
|
76
|
+
- lib/kodo/tools/fetch_url.rb
|
|
70
77
|
- lib/kodo/tools/forget_fact.rb
|
|
71
78
|
- lib/kodo/tools/get_current_time.rb
|
|
72
79
|
- lib/kodo/tools/list_reminders.rb
|
|
80
|
+
- lib/kodo/tools/prompt_contributor.rb
|
|
73
81
|
- lib/kodo/tools/recall_facts.rb
|
|
74
82
|
- lib/kodo/tools/remember_fact.rb
|
|
75
83
|
- lib/kodo/tools/set_reminder.rb
|
|
84
|
+
- lib/kodo/tools/store_secret.rb
|
|
76
85
|
- lib/kodo/tools/update_fact.rb
|
|
86
|
+
- lib/kodo/tools/web_search.rb
|
|
77
87
|
- lib/kodo/version.rb
|
|
78
88
|
homepage: https://kodo.bot
|
|
79
89
|
licenses:
|