kodo-bot 0.2.0 → 0.2.1
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 +64 -15
- data/lib/kodo/config.rb +1 -0
- data/lib/kodo/daemon.rb +5 -1
- data/lib/kodo/heartbeat.rb +37 -3
- data/lib/kodo/llm.rb +3 -0
- data/lib/kodo/memory/reminders.rb +111 -0
- data/lib/kodo/router.rb +35 -7
- data/lib/kodo/tools/dismiss_reminder.rb +37 -0
- data/lib/kodo/tools/get_current_time.rb +44 -0
- data/lib/kodo/tools/list_reminders.rb +36 -0
- data/lib/kodo/tools/recall_facts.rb +46 -0
- data/lib/kodo/tools/set_reminder.rb +84 -0
- data/lib/kodo/tools/update_fact.rb +69 -0
- data/lib/kodo/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28db2b743e15b691e4c20712a38e822add6c2e559edf75780a9a82887695dfee
|
|
4
|
+
data.tar.gz: ddc6dc895a4b102506c2a68f5d497053dba8c6bf34ea09150ba47d25e3c8eab8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f475e0f066e08ae275093769a245a1a8e2d88147a8a0aa76985968365fd84cb12d530cfdee3c5afb69ca177f56d24437a725fe9ce4b451a3b61778c183d2745f
|
|
7
|
+
data.tar.gz: 703e9236a4338a9441e28aadb0d00ce2ab43e9f689fa79ff7fea63578e852c4682bd3ac4384706ae4f01d3acc376a1638791032def2f6d185b5cb2d2e4d53410
|
data/bin/kodo
CHANGED
|
@@ -6,25 +6,27 @@ require_relative "../lib/kodo"
|
|
|
6
6
|
module Kodo
|
|
7
7
|
class CLI
|
|
8
8
|
COMMANDS = {
|
|
9
|
-
"start"
|
|
10
|
-
"chat"
|
|
11
|
-
"memories"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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"
|
|
16
17
|
}.freeze
|
|
17
18
|
|
|
18
19
|
def run(args = ARGV)
|
|
19
20
|
command = args.first || "help"
|
|
20
21
|
|
|
21
22
|
case command
|
|
22
|
-
when "start"
|
|
23
|
-
when "chat"
|
|
24
|
-
when "memories"
|
|
25
|
-
when "
|
|
26
|
-
when "
|
|
27
|
-
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
|
|
28
30
|
else help
|
|
29
31
|
end
|
|
30
32
|
end
|
|
@@ -61,7 +63,14 @@ module Kodo
|
|
|
61
63
|
memory = Memory::Store.new(passphrase: passphrase)
|
|
62
64
|
audit = Memory::Audit.new
|
|
63
65
|
knowledge = Memory::Knowledge.new(passphrase: passphrase)
|
|
64
|
-
|
|
66
|
+
reminder_store = Memory::Reminders.new(passphrase: passphrase)
|
|
67
|
+
router = Router.new(
|
|
68
|
+
memory: memory,
|
|
69
|
+
audit: audit,
|
|
70
|
+
prompt_assembler: assembler,
|
|
71
|
+
knowledge: knowledge,
|
|
72
|
+
reminders: reminder_store
|
|
73
|
+
)
|
|
65
74
|
console = Channels::Console.new
|
|
66
75
|
console.connect!
|
|
67
76
|
|
|
@@ -77,7 +86,7 @@ module Kodo
|
|
|
77
86
|
metadata: { chat_id: "console" }
|
|
78
87
|
)
|
|
79
88
|
|
|
80
|
-
response = router.route(message, channel: console)
|
|
89
|
+
response = with_spinner { router.route(message, channel: console) }
|
|
81
90
|
console.send_message(response)
|
|
82
91
|
end
|
|
83
92
|
rescue Interrupt
|
|
@@ -113,6 +122,27 @@ module Kodo
|
|
|
113
122
|
end
|
|
114
123
|
end
|
|
115
124
|
|
|
125
|
+
def reminders
|
|
126
|
+
Config.ensure_home_dir!
|
|
127
|
+
passphrase = Kodo.config.memory_encryption? ? Kodo.config.memory_passphrase : nil
|
|
128
|
+
reminder_store = Memory::Reminders.new(passphrase: passphrase)
|
|
129
|
+
|
|
130
|
+
active = reminder_store.all_active.sort_by { |r| r["due_at"] }
|
|
131
|
+
if active.empty?
|
|
132
|
+
puts "No active reminders."
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
puts "Active reminders (#{active.length}):"
|
|
137
|
+
puts ""
|
|
138
|
+
|
|
139
|
+
active.each do |r|
|
|
140
|
+
puts " - #{r['content']}"
|
|
141
|
+
puts " due: #{r['due_at']} id: #{r['id']}"
|
|
142
|
+
end
|
|
143
|
+
puts ""
|
|
144
|
+
end
|
|
145
|
+
|
|
116
146
|
def init
|
|
117
147
|
Config.ensure_home_dir!
|
|
118
148
|
PromptAssembler.new.ensure_default_files!
|
|
@@ -170,6 +200,25 @@ module Kodo
|
|
|
170
200
|
puts " Telegram: #{ENV["TELEGRAM_BOT_TOKEN"] ? "✅ set" : "❌ TELEGRAM_BOT_TOKEN missing"}"
|
|
171
201
|
end
|
|
172
202
|
|
|
203
|
+
def with_spinner
|
|
204
|
+
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
205
|
+
done = false
|
|
206
|
+
spinner = Thread.new do
|
|
207
|
+
i = 0
|
|
208
|
+
while !done
|
|
209
|
+
print "\r\e[36m#{frames[i % frames.length]} thinking...\e[0m"
|
|
210
|
+
i += 1
|
|
211
|
+
sleep 0.1
|
|
212
|
+
end
|
|
213
|
+
print "\r\e[K"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
result = yield
|
|
217
|
+
done = true
|
|
218
|
+
spinner.join
|
|
219
|
+
result
|
|
220
|
+
end
|
|
221
|
+
|
|
173
222
|
def help
|
|
174
223
|
puts "🥁 Kodo v#{VERSION} — your personal AI agent"
|
|
175
224
|
puts ""
|
data/lib/kodo/config.rb
CHANGED
|
@@ -69,6 +69,7 @@ module Kodo
|
|
|
69
69
|
Kodo.home_dir,
|
|
70
70
|
File.join(Kodo.home_dir, "memory", "conversations"),
|
|
71
71
|
File.join(Kodo.home_dir, "memory", "knowledge"),
|
|
72
|
+
File.join(Kodo.home_dir, "memory", "reminders"),
|
|
72
73
|
File.join(Kodo.home_dir, "memory", "audit"),
|
|
73
74
|
File.join(Kodo.home_dir, "skills")
|
|
74
75
|
]
|
data/lib/kodo/daemon.rb
CHANGED
|
@@ -12,12 +12,14 @@ module Kodo
|
|
|
12
12
|
@memory = Memory::Store.new(passphrase: passphrase)
|
|
13
13
|
@audit = Memory::Audit.new
|
|
14
14
|
@knowledge = Memory::Knowledge.new(passphrase: passphrase)
|
|
15
|
+
@reminders = Memory::Reminders.new(passphrase: passphrase)
|
|
15
16
|
@prompt_assembler = PromptAssembler.new
|
|
16
17
|
@router = Router.new(
|
|
17
18
|
memory: @memory,
|
|
18
19
|
audit: @audit,
|
|
19
20
|
prompt_assembler: @prompt_assembler,
|
|
20
|
-
knowledge: @knowledge
|
|
21
|
+
knowledge: @knowledge,
|
|
22
|
+
reminders: @reminders
|
|
21
23
|
)
|
|
22
24
|
@channels = []
|
|
23
25
|
end
|
|
@@ -43,6 +45,7 @@ module Kodo
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
Kodo.logger.info(" Knowledge facts: #{@knowledge.count}")
|
|
48
|
+
Kodo.logger.info(" Active reminders: #{@reminders.active_count}")
|
|
46
49
|
|
|
47
50
|
start_heartbeat!
|
|
48
51
|
end
|
|
@@ -90,6 +93,7 @@ module Kodo
|
|
|
90
93
|
channels: @channels,
|
|
91
94
|
router: @router,
|
|
92
95
|
audit: @audit,
|
|
96
|
+
reminders: @reminders,
|
|
93
97
|
interval: @heartbeat_interval
|
|
94
98
|
)
|
|
95
99
|
|
data/lib/kodo/heartbeat.rb
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Kodo
|
|
4
4
|
class Heartbeat
|
|
5
|
-
def initialize(channels:, router:, audit:, interval: 60)
|
|
5
|
+
def initialize(channels:, router:, audit:, reminders: nil, interval: 60)
|
|
6
6
|
@channels = channels
|
|
7
7
|
@router = router
|
|
8
8
|
@audit = audit
|
|
9
|
+
@reminders = reminders
|
|
9
10
|
@interval = interval
|
|
10
11
|
@running = false
|
|
11
12
|
@beat_count = 0
|
|
@@ -48,8 +49,8 @@ module Kodo
|
|
|
48
49
|
process_message(message, channel)
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
# Phase 3:
|
|
52
|
-
|
|
52
|
+
# Phase 3: Reminders — deliver any due reminders
|
|
53
|
+
deliver_due_reminders!
|
|
53
54
|
|
|
54
55
|
rescue StandardError => e
|
|
55
56
|
Kodo.logger.error("Heartbeat error: #{e.message}")
|
|
@@ -75,6 +76,39 @@ module Kodo
|
|
|
75
76
|
messages
|
|
76
77
|
end
|
|
77
78
|
|
|
79
|
+
def deliver_due_reminders!
|
|
80
|
+
return unless @reminders
|
|
81
|
+
|
|
82
|
+
@reminders.due_reminders.each do |reminder|
|
|
83
|
+
channel = find_channel(reminder["channel_id"])
|
|
84
|
+
next unless channel
|
|
85
|
+
|
|
86
|
+
message = Message.new(
|
|
87
|
+
channel_id: reminder["channel_id"],
|
|
88
|
+
sender: :agent,
|
|
89
|
+
content: "Reminder: #{reminder['content']}",
|
|
90
|
+
metadata: { chat_id: reminder["chat_id"] }
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
channel.send_message(message)
|
|
94
|
+
@reminders.fire!(reminder["id"])
|
|
95
|
+
|
|
96
|
+
@audit.log(
|
|
97
|
+
event: "reminder_fired",
|
|
98
|
+
channel: reminder["channel_id"],
|
|
99
|
+
detail: "id:#{reminder['id']} content:#{reminder['content']&.slice(0, 60)}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
Kodo.logger.info("Fired reminder: #{reminder['content']&.slice(0, 60)}")
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Kodo.logger.error("Error firing reminder #{reminder['id']}: #{e.message}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def find_channel(channel_id)
|
|
109
|
+
@channels.find { |c| c.channel_id == channel_id && c.running? }
|
|
110
|
+
end
|
|
111
|
+
|
|
78
112
|
def process_message(message, channel)
|
|
79
113
|
Kodo.logger.info("Processing: [#{channel.channel_id}] #{message.content.slice(0, 60)}...")
|
|
80
114
|
|
data/lib/kodo/llm.rb
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Kodo
|
|
8
|
+
module Memory
|
|
9
|
+
class Reminders
|
|
10
|
+
MAX_ACTIVE = 50
|
|
11
|
+
MAX_CONTENT_LENGTH = 500
|
|
12
|
+
|
|
13
|
+
def initialize(passphrase: nil)
|
|
14
|
+
@reminders_dir = File.join(Kodo.home_dir, "memory", "reminders")
|
|
15
|
+
@passphrase = passphrase
|
|
16
|
+
FileUtils.mkdir_p(@reminders_dir)
|
|
17
|
+
@reminders = load_reminders
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add(content:, due_at:, channel_id: nil, chat_id: nil)
|
|
21
|
+
if active_count >= MAX_ACTIVE
|
|
22
|
+
raise Kodo::Error, "Too many active reminders (max #{MAX_ACTIVE}). Dismiss some first."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
now = Time.now.iso8601
|
|
26
|
+
reminder = {
|
|
27
|
+
"id" => SecureRandom.uuid,
|
|
28
|
+
"content" => content,
|
|
29
|
+
"due_at" => due_at.is_a?(Time) ? due_at.iso8601 : due_at,
|
|
30
|
+
"channel_id" => channel_id,
|
|
31
|
+
"chat_id" => chat_id,
|
|
32
|
+
"status" => "active",
|
|
33
|
+
"created_at" => now
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@reminders << reminder
|
|
37
|
+
save_reminders
|
|
38
|
+
reminder
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dismiss(id)
|
|
42
|
+
reminder = @reminders.find { |r| r["id"] == id && r["status"] == "active" }
|
|
43
|
+
return nil unless reminder
|
|
44
|
+
|
|
45
|
+
reminder["status"] = "dismissed"
|
|
46
|
+
save_reminders
|
|
47
|
+
reminder
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fire!(id)
|
|
51
|
+
reminder = @reminders.find { |r| r["id"] == id && r["status"] == "active" }
|
|
52
|
+
return nil unless reminder
|
|
53
|
+
|
|
54
|
+
reminder["status"] = "fired"
|
|
55
|
+
save_reminders
|
|
56
|
+
reminder
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def due_reminders
|
|
60
|
+
now = Time.now
|
|
61
|
+
all_active.select { |r| Time.parse(r["due_at"]) <= now }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def all_active
|
|
65
|
+
@reminders.select { |r| r["status"] == "active" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def active_count
|
|
69
|
+
all_active.length
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def reminders_path
|
|
75
|
+
File.join(@reminders_dir, "reminders.jsonl")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load_reminders
|
|
79
|
+
path = reminders_path
|
|
80
|
+
return [] unless File.exist?(path)
|
|
81
|
+
|
|
82
|
+
raw = File.binread(path)
|
|
83
|
+
|
|
84
|
+
if Encryption.encrypted?(raw)
|
|
85
|
+
raise Kodo::Error, "Encrypted reminders file but no passphrase provided" unless @passphrase
|
|
86
|
+
raw = Encryption.decrypt(raw, key: @passphrase)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
raw.each_line.filter_map do |line|
|
|
90
|
+
line = line.strip
|
|
91
|
+
next if line.empty?
|
|
92
|
+
JSON.parse(line)
|
|
93
|
+
end
|
|
94
|
+
rescue JSON::ParserError => e
|
|
95
|
+
Kodo.logger.warn("Corrupt reminders file #{path}: #{e.message}")
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def save_reminders
|
|
100
|
+
path = reminders_path
|
|
101
|
+
jsonl = @reminders.map { |r| JSON.generate(r) }.join("\n") + "\n"
|
|
102
|
+
|
|
103
|
+
if @passphrase
|
|
104
|
+
File.binwrite(path, Encryption.encrypt(jsonl, key: @passphrase))
|
|
105
|
+
else
|
|
106
|
+
File.write(path, jsonl)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/kodo/router.rb
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Kodo
|
|
4
4
|
class Router
|
|
5
|
-
def initialize(memory:, audit:, prompt_assembler: nil, knowledge: nil)
|
|
5
|
+
def initialize(memory:, audit:, prompt_assembler: nil, knowledge: nil, reminders: nil)
|
|
6
6
|
@memory = memory
|
|
7
7
|
@audit = audit
|
|
8
8
|
@prompt_assembler = prompt_assembler || PromptAssembler.new
|
|
9
9
|
@knowledge = knowledge
|
|
10
|
+
@reminders = reminders
|
|
10
11
|
@tools = build_tools
|
|
11
12
|
end
|
|
12
13
|
|
|
@@ -14,6 +15,9 @@ module Kodo
|
|
|
14
15
|
def route(message, channel:)
|
|
15
16
|
chat_id = message.metadata[:chat_id] || message.metadata["chat_id"]
|
|
16
17
|
|
|
18
|
+
# Set channel context on SetReminder so it knows where to deliver
|
|
19
|
+
set_reminder_context(channel.channel_id, chat_id)
|
|
20
|
+
|
|
17
21
|
# Store the user's message
|
|
18
22
|
@memory.append(chat_id, role: "user", content: message.content)
|
|
19
23
|
|
|
@@ -37,7 +41,7 @@ module Kodo
|
|
|
37
41
|
chat = LLM.chat
|
|
38
42
|
chat.with_instructions(system_prompt)
|
|
39
43
|
|
|
40
|
-
# Register tools
|
|
44
|
+
# Register tools with the LLM chat
|
|
41
45
|
if @tools.any?
|
|
42
46
|
reset_tool_rate_limits!
|
|
43
47
|
chat.with_tools(*@tools)
|
|
@@ -75,12 +79,27 @@ module Kodo
|
|
|
75
79
|
private
|
|
76
80
|
|
|
77
81
|
def build_tools
|
|
78
|
-
|
|
82
|
+
tools = []
|
|
83
|
+
|
|
84
|
+
# Always available
|
|
85
|
+
tools << Tools::GetCurrentTime.new(audit: @audit)
|
|
86
|
+
|
|
87
|
+
# Knowledge tools (require knowledge store)
|
|
88
|
+
if @knowledge
|
|
89
|
+
tools << Tools::RememberFact.new(knowledge: @knowledge, audit: @audit)
|
|
90
|
+
tools << Tools::ForgetFact.new(knowledge: @knowledge, audit: @audit)
|
|
91
|
+
tools << Tools::RecallFacts.new(knowledge: @knowledge, audit: @audit)
|
|
92
|
+
tools << Tools::UpdateFact.new(knowledge: @knowledge, audit: @audit)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Reminder tools (require reminders store)
|
|
96
|
+
if @reminders
|
|
97
|
+
tools << Tools::SetReminder.new(reminders: @reminders, audit: @audit)
|
|
98
|
+
tools << Tools::ListReminders.new(reminders: @reminders, audit: @audit)
|
|
99
|
+
tools << Tools::DismissReminder.new(reminders: @reminders, audit: @audit)
|
|
100
|
+
end
|
|
79
101
|
|
|
80
|
-
|
|
81
|
-
Tools::RememberFact.new(knowledge: @knowledge, audit: @audit),
|
|
82
|
-
Tools::ForgetFact.new(knowledge: @knowledge, audit: @audit)
|
|
83
|
-
]
|
|
102
|
+
tools
|
|
84
103
|
end
|
|
85
104
|
|
|
86
105
|
def reset_tool_rate_limits!
|
|
@@ -88,5 +107,14 @@ module Kodo
|
|
|
88
107
|
tool.reset_turn_count! if tool.respond_to?(:reset_turn_count!)
|
|
89
108
|
end
|
|
90
109
|
end
|
|
110
|
+
|
|
111
|
+
def set_reminder_context(channel_id, chat_id)
|
|
112
|
+
@tools.each do |tool|
|
|
113
|
+
if tool.is_a?(Tools::SetReminder)
|
|
114
|
+
tool.channel_id = channel_id
|
|
115
|
+
tool.chat_id = chat_id
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
91
119
|
end
|
|
92
120
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Kodo
|
|
6
|
+
module Tools
|
|
7
|
+
class DismissReminder < RubyLLM::Tool
|
|
8
|
+
description "Dismiss (cancel) an active reminder by its ID."
|
|
9
|
+
|
|
10
|
+
param :id, desc: "The UUID of the reminder to dismiss"
|
|
11
|
+
|
|
12
|
+
def initialize(reminders:, audit:)
|
|
13
|
+
super()
|
|
14
|
+
@reminders = reminders
|
|
15
|
+
@audit = audit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def execute(id:)
|
|
19
|
+
reminder = @reminders.dismiss(id)
|
|
20
|
+
|
|
21
|
+
if reminder
|
|
22
|
+
@audit.log(
|
|
23
|
+
event: "reminder_dismissed",
|
|
24
|
+
detail: "id:#{id} content:#{reminder['content']&.slice(0, 60)}"
|
|
25
|
+
)
|
|
26
|
+
"Dismissed reminder: #{reminder['content']}"
|
|
27
|
+
else
|
|
28
|
+
"No active reminder found with id: #{id}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def name
|
|
33
|
+
"dismiss_reminder"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Kodo
|
|
6
|
+
module Tools
|
|
7
|
+
class GetCurrentTime < RubyLLM::Tool
|
|
8
|
+
description "Get the current date and time. Use this when you need to know what time it is, " \
|
|
9
|
+
"what day of the week it is, or make time-relative decisions."
|
|
10
|
+
|
|
11
|
+
def initialize(audit:)
|
|
12
|
+
super()
|
|
13
|
+
@audit = audit
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
now = Time.now
|
|
18
|
+
day_name = now.strftime("%A")
|
|
19
|
+
date_str = now.strftime("%Y-%m-%d")
|
|
20
|
+
time_str = now.strftime("%H:%M:%S %Z")
|
|
21
|
+
period = time_period(now.hour)
|
|
22
|
+
|
|
23
|
+
@audit.log(event: "tool_get_current_time", detail: "#{date_str} #{time_str}")
|
|
24
|
+
|
|
25
|
+
"#{day_name}, #{date_str} #{time_str} (#{period})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def name
|
|
29
|
+
"get_current_time"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def time_period(hour)
|
|
35
|
+
case hour
|
|
36
|
+
when 5..11 then "morning"
|
|
37
|
+
when 12..16 then "afternoon"
|
|
38
|
+
when 17..20 then "evening"
|
|
39
|
+
else "night"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Kodo
|
|
6
|
+
module Tools
|
|
7
|
+
class ListReminders < RubyLLM::Tool
|
|
8
|
+
description "List all active reminders, sorted by due time."
|
|
9
|
+
|
|
10
|
+
def initialize(reminders:, audit:)
|
|
11
|
+
super()
|
|
12
|
+
@reminders = reminders
|
|
13
|
+
@audit = audit
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
active = @reminders.all_active.sort_by { |r| r["due_at"] }
|
|
18
|
+
|
|
19
|
+
@audit.log(event: "tool_list_reminders", detail: "count:#{active.length}")
|
|
20
|
+
|
|
21
|
+
if active.empty?
|
|
22
|
+
"No active reminders."
|
|
23
|
+
else
|
|
24
|
+
lines = active.map do |r|
|
|
25
|
+
"- [#{r['due_at']}] #{r['content']} (id: #{r['id']})"
|
|
26
|
+
end
|
|
27
|
+
"#{active.length} active reminder(s):\n#{lines.join("\n")}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def name
|
|
32
|
+
"list_reminders"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Kodo
|
|
6
|
+
module Tools
|
|
7
|
+
class RecallFacts < RubyLLM::Tool
|
|
8
|
+
description "Search your knowledge store for facts about the user. " \
|
|
9
|
+
"Use this when you need to look up specific information, especially " \
|
|
10
|
+
"if the knowledge was truncated in your system prompt."
|
|
11
|
+
|
|
12
|
+
param :query, desc: "Keyword to search for in fact content (case-insensitive)", required: false
|
|
13
|
+
param :category, desc: "Filter by category: preference, fact, instruction, or context", required: false
|
|
14
|
+
|
|
15
|
+
def initialize(knowledge:, audit:)
|
|
16
|
+
super()
|
|
17
|
+
@knowledge = knowledge
|
|
18
|
+
@audit = audit
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute(query: nil, category: nil)
|
|
22
|
+
if category && !Memory::Knowledge::VALID_CATEGORIES.include?(category)
|
|
23
|
+
return "Invalid category '#{category}'. Use: #{Memory::Knowledge::VALID_CATEGORIES.join(', ')}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
results = @knowledge.recall(query: query, category: category)
|
|
27
|
+
|
|
28
|
+
@audit.log(
|
|
29
|
+
event: "tool_recall_facts",
|
|
30
|
+
detail: "query:#{query || '*'} cat:#{category || '*'} results:#{results.length}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if results.empty?
|
|
34
|
+
"No facts found#{" matching '#{query}'" if query}#{" in category '#{category}'" if category}."
|
|
35
|
+
else
|
|
36
|
+
lines = results.map { |f| "- [#{f['category']}] #{f['content']} (id: #{f['id']})" }
|
|
37
|
+
"Found #{results.length} fact(s):\n#{lines.join("\n")}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def name
|
|
42
|
+
"recall_facts"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Kodo
|
|
7
|
+
module Tools
|
|
8
|
+
class SetReminder < RubyLLM::Tool
|
|
9
|
+
MAX_PER_TURN = 3
|
|
10
|
+
MAX_CONTENT_LENGTH = 500
|
|
11
|
+
|
|
12
|
+
description "Set a reminder for a future time. The reminder will be delivered proactively " \
|
|
13
|
+
"when the time arrives, even if the user hasn't sent a message."
|
|
14
|
+
|
|
15
|
+
param :content, desc: "What to remind the user about (max 500 chars)"
|
|
16
|
+
param :due_at, desc: "When to deliver the reminder, in ISO 8601 format (e.g. 2025-01-15T14:30:00-05:00)"
|
|
17
|
+
|
|
18
|
+
attr_writer :channel_id, :chat_id
|
|
19
|
+
|
|
20
|
+
def initialize(reminders:, audit:)
|
|
21
|
+
super()
|
|
22
|
+
@reminders = reminders
|
|
23
|
+
@audit = audit
|
|
24
|
+
@turn_count = 0
|
|
25
|
+
@channel_id = nil
|
|
26
|
+
@chat_id = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reset_turn_count!
|
|
30
|
+
@turn_count = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def execute(content:, due_at:)
|
|
34
|
+
if content.length > MAX_CONTENT_LENGTH
|
|
35
|
+
return "Content too long (#{content.length} chars). Maximum is #{MAX_CONTENT_LENGTH}."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if Memory::Redactor.sensitive?(content)
|
|
39
|
+
return "Cannot store sensitive data in reminders."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@turn_count += 1
|
|
43
|
+
if @turn_count > MAX_PER_TURN
|
|
44
|
+
return "Rate limit reached (max #{MAX_PER_TURN} reminders per message). Try again next message."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
parsed_time = parse_time(due_at)
|
|
48
|
+
return parsed_time if parsed_time.is_a?(String) # error message
|
|
49
|
+
|
|
50
|
+
if parsed_time <= Time.now
|
|
51
|
+
return "Cannot set a reminder in the past. Please provide a future time."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
reminder = @reminders.add(
|
|
55
|
+
content: content,
|
|
56
|
+
due_at: parsed_time,
|
|
57
|
+
channel_id: @channel_id,
|
|
58
|
+
chat_id: @chat_id
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@audit.log(
|
|
62
|
+
event: "reminder_set",
|
|
63
|
+
detail: "id:#{reminder['id']} due:#{reminder['due_at']} content:#{content.slice(0, 60)}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
"Reminder set for #{reminder['due_at']}: #{content} (id: #{reminder['id']})"
|
|
67
|
+
rescue Kodo::Error => e
|
|
68
|
+
e.message
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def name
|
|
72
|
+
"set_reminder"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def parse_time(due_at)
|
|
78
|
+
Time.parse(due_at)
|
|
79
|
+
rescue ArgumentError, TypeError
|
|
80
|
+
"Invalid time format '#{due_at}'. Use ISO 8601 (e.g. 2025-01-15T14:30:00-05:00)."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Kodo
|
|
6
|
+
module Tools
|
|
7
|
+
class UpdateFact < RubyLLM::Tool
|
|
8
|
+
MAX_PER_TURN = 5
|
|
9
|
+
MAX_CONTENT_LENGTH = 500
|
|
10
|
+
|
|
11
|
+
description "Update an existing fact with new content. Use this instead of forget+remember " \
|
|
12
|
+
"when a fact needs correction or updating."
|
|
13
|
+
|
|
14
|
+
param :id, desc: "The UUID of the fact to update"
|
|
15
|
+
param :content, desc: "The new content for the fact (max 500 chars)"
|
|
16
|
+
|
|
17
|
+
def initialize(knowledge:, audit:)
|
|
18
|
+
super()
|
|
19
|
+
@knowledge = knowledge
|
|
20
|
+
@audit = audit
|
|
21
|
+
@turn_count = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reset_turn_count!
|
|
25
|
+
@turn_count = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute(id:, content:)
|
|
29
|
+
if content.length > MAX_CONTENT_LENGTH
|
|
30
|
+
return "Content too long (#{content.length} chars). Maximum is #{MAX_CONTENT_LENGTH}."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if Memory::Redactor.sensitive?(content)
|
|
34
|
+
return "Cannot store sensitive data (passwords, API keys, SSNs, credit card numbers)."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@turn_count += 1
|
|
38
|
+
if @turn_count > MAX_PER_TURN
|
|
39
|
+
return "Rate limit reached (max #{MAX_PER_TURN} updates per message). Try again next message."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
old_fact = @knowledge.all_active.find { |f| f["id"] == id }
|
|
43
|
+
unless old_fact
|
|
44
|
+
return "No active fact found with id: #{id}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@knowledge.forget(id)
|
|
48
|
+
new_fact = @knowledge.remember(
|
|
49
|
+
category: old_fact["category"],
|
|
50
|
+
content: content,
|
|
51
|
+
source: old_fact["source"]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@audit.log(
|
|
55
|
+
event: "knowledge_updated",
|
|
56
|
+
detail: "old:#{id} new:#{new_fact['id']} cat:#{old_fact['category']}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
"Updated fact: #{content} (new id: #{new_fact['id']}, replaces: #{id})"
|
|
60
|
+
rescue Kodo::Error => e
|
|
61
|
+
e.message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def name
|
|
65
|
+
"update_fact"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
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.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Freedom Dumlao
|
|
@@ -61,12 +61,19 @@ files:
|
|
|
61
61
|
- lib/kodo/memory/encryption.rb
|
|
62
62
|
- lib/kodo/memory/knowledge.rb
|
|
63
63
|
- lib/kodo/memory/redactor.rb
|
|
64
|
+
- lib/kodo/memory/reminders.rb
|
|
64
65
|
- lib/kodo/memory/store.rb
|
|
65
66
|
- lib/kodo/message.rb
|
|
66
67
|
- lib/kodo/prompt_assembler.rb
|
|
67
68
|
- lib/kodo/router.rb
|
|
69
|
+
- lib/kodo/tools/dismiss_reminder.rb
|
|
68
70
|
- lib/kodo/tools/forget_fact.rb
|
|
71
|
+
- lib/kodo/tools/get_current_time.rb
|
|
72
|
+
- lib/kodo/tools/list_reminders.rb
|
|
73
|
+
- lib/kodo/tools/recall_facts.rb
|
|
69
74
|
- lib/kodo/tools/remember_fact.rb
|
|
75
|
+
- lib/kodo/tools/set_reminder.rb
|
|
76
|
+
- lib/kodo/tools/update_fact.rb
|
|
70
77
|
- lib/kodo/version.rb
|
|
71
78
|
homepage: https://kodo.bot
|
|
72
79
|
licenses:
|