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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4c50c7a3b926a9094c06ddd7615226ddfd187f0c385e0435b34a066e7618cc0
4
- data.tar.gz: d4a4535bab02df618af02330b3a0bdf8521ece8c022e96f019da0bff65ea20c4
3
+ metadata.gz: 28db2b743e15b691e4c20712a38e822add6c2e559edf75780a9a82887695dfee
4
+ data.tar.gz: ddc6dc895a4b102506c2a68f5d497053dba8c6bf34ea09150ba47d25e3c8eab8
5
5
  SHA512:
6
- metadata.gz: 7f7a3f02b91efd833009737cf3858d91dfa0dad9018dc38cd704fa91d7a064cc4d8960928de41d16a4e31be64a721deeed3c44bd71c6a643a8ff282bed1d4923
7
- data.tar.gz: 1bdcdddfe5afa5960fbf6ec77e766e4e3b0c2f326ca29df9d2e9a0078c90173f42e18835b1879101a32ac979ff2f9867158787859d92759e0a00e3fdc3facf1d
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" => "Start the Kodo daemon",
10
- "chat" => "Chat with Kodo directly in the terminal",
11
- "memories" => "List what Kodo remembers about you",
12
- "status" => "Show daemon status",
13
- "version" => "Show version",
14
- "init" => "Create default config and prompt files in ~/.kodo/",
15
- "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"
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" then start(args)
23
- when "chat" then chat
24
- when "memories" then memories
25
- when "init" then init
26
- when "version" then puts "kodo v#{VERSION}"
27
- 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
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
- router = Router.new(memory: memory, audit: audit, prompt_assembler: assembler, knowledge: knowledge)
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
 
@@ -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: Schedulecheck cron-like pulses (future)
52
- # TODO: scheduled tasks
52
+ # Phase 3: Remindersdeliver 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
@@ -18,6 +18,9 @@ module Kodo
18
18
  end
19
19
  end
20
20
 
21
+ # Refresh model registry so newly released models are recognized
22
+ RubyLLM.models.refresh!
23
+
21
24
  Kodo.logger.info("LLM configured: #{config.llm_model}")
22
25
  end
23
26
 
@@ -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 if knowledge store is available
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
- return [] unless @knowledge
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kodo
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
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.0
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: