nanobot 0.1.0
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 +7 -0
- data/Gemfile +32 -0
- data/LICENSE +21 -0
- data/README.md +530 -0
- data/bin/nanobot +7 -0
- data/bin/record-integrations +38 -0
- data/lib/nanobot/agent/context.rb +132 -0
- data/lib/nanobot/agent/loop.rb +336 -0
- data/lib/nanobot/agent/memory.rb +131 -0
- data/lib/nanobot/agent/tools/filesystem.rb +241 -0
- data/lib/nanobot/agent/tools/schedule.rb +94 -0
- data/lib/nanobot/agent/tools/shell.rb +181 -0
- data/lib/nanobot/agent/tools/web.rb +216 -0
- data/lib/nanobot/bus/events.rb +70 -0
- data/lib/nanobot/bus/message_bus.rb +152 -0
- data/lib/nanobot/channels/base.rb +94 -0
- data/lib/nanobot/channels/discord.rb +64 -0
- data/lib/nanobot/channels/email.rb +253 -0
- data/lib/nanobot/channels/gateway.rb +105 -0
- data/lib/nanobot/channels/manager.rb +128 -0
- data/lib/nanobot/channels/slack.rb +162 -0
- data/lib/nanobot/channels/telegram.rb +94 -0
- data/lib/nanobot/cli/commands.rb +444 -0
- data/lib/nanobot/config/loader.rb +204 -0
- data/lib/nanobot/config/schema.rb +296 -0
- data/lib/nanobot/providers/base.rb +43 -0
- data/lib/nanobot/providers/rubyllm_provider.rb +254 -0
- data/lib/nanobot/scheduler/service.rb +108 -0
- data/lib/nanobot/scheduler/store.rb +233 -0
- data/lib/nanobot/session/manager.rb +225 -0
- data/lib/nanobot/version.rb +6 -0
- data/lib/nanobot.rb +23 -0
- metadata +176 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require 'logger'
|
|
5
|
+
require_relative 'memory'
|
|
6
|
+
|
|
7
|
+
module Nanobot
|
|
8
|
+
module Agent
|
|
9
|
+
# ContextBuilder assembles the system prompt and message context for LLM calls
|
|
10
|
+
class ContextBuilder
|
|
11
|
+
BOOTSTRAP_FILES = %w[AGENTS.md SOUL.md USER.md TOOLS.md IDENTITY.md].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :workspace, :memory_store
|
|
14
|
+
|
|
15
|
+
# @param workspace [String, Pathname] path to the agent workspace directory
|
|
16
|
+
# @param logger [Logger, nil] optional logger instance
|
|
17
|
+
def initialize(workspace, logger: nil)
|
|
18
|
+
@workspace = Pathname.new(workspace).expand_path
|
|
19
|
+
@memory_store = MemoryStore.new(@workspace)
|
|
20
|
+
@logger = logger || Logger.new(IO::NULL)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Build the complete system prompt
|
|
24
|
+
# @return [String]
|
|
25
|
+
def build_system_prompt
|
|
26
|
+
parts = []
|
|
27
|
+
|
|
28
|
+
# Runtime information
|
|
29
|
+
parts << build_runtime_info
|
|
30
|
+
|
|
31
|
+
# Bootstrap files
|
|
32
|
+
BOOTSTRAP_FILES.each do |filename|
|
|
33
|
+
content = read_bootstrap_file(filename)
|
|
34
|
+
if content
|
|
35
|
+
parts << content
|
|
36
|
+
@logger.debug "Loaded bootstrap file: #{filename} (#{content.length} chars)"
|
|
37
|
+
else
|
|
38
|
+
@logger.debug "Bootstrap file not found or empty: #{filename}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Memory context
|
|
43
|
+
memory_context = @memory_store.get_memory_context
|
|
44
|
+
if memory_context
|
|
45
|
+
parts << "# Memory\n\n#{memory_context}"
|
|
46
|
+
@logger.debug "Loaded memory context (#{memory_context.length} chars)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
prompt = parts.join("\n\n---\n\n")
|
|
50
|
+
@logger.debug "System prompt assembled: #{prompt.length} chars total"
|
|
51
|
+
prompt
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Build messages array for LLM
|
|
55
|
+
# @param history [Array<Hash>] conversation history
|
|
56
|
+
# @param current_message [String] current user message
|
|
57
|
+
# @param channel [String, nil] current channel name
|
|
58
|
+
# @param chat_id [String, nil] current chat ID
|
|
59
|
+
# @return [Array<Hash>] messages in OpenAI format
|
|
60
|
+
def build_messages(current_message:, history: [], channel: nil, chat_id: nil)
|
|
61
|
+
messages = []
|
|
62
|
+
|
|
63
|
+
# System prompt
|
|
64
|
+
system_prompt = build_system_prompt
|
|
65
|
+
messages << { role: 'system', content: system_prompt }
|
|
66
|
+
|
|
67
|
+
# Add history
|
|
68
|
+
history.each do |msg|
|
|
69
|
+
messages << msg
|
|
70
|
+
end
|
|
71
|
+
@logger.debug "History: #{history.length} messages"
|
|
72
|
+
|
|
73
|
+
# Add channel context as a separate system message if provided
|
|
74
|
+
if channel && chat_id
|
|
75
|
+
messages << { role: 'system', content: "Current channel: #{channel}, Chat ID: #{chat_id}" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Add current message
|
|
79
|
+
messages << { role: 'user', content: current_message }
|
|
80
|
+
@logger.debug "Built #{messages.length} messages for LLM"
|
|
81
|
+
|
|
82
|
+
messages
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Add a tool result to messages
|
|
86
|
+
# @param messages [Array<Hash>] existing messages
|
|
87
|
+
# @param tool_call_id [String] ID of the tool call
|
|
88
|
+
# @param tool_name [String] name of the tool
|
|
89
|
+
# @param result [String] tool execution result
|
|
90
|
+
# @return [Array<Hash>] updated messages
|
|
91
|
+
def add_tool_result(messages, tool_call_id, tool_name, result)
|
|
92
|
+
messages << {
|
|
93
|
+
role: 'tool',
|
|
94
|
+
tool_call_id: tool_call_id,
|
|
95
|
+
name: tool_name,
|
|
96
|
+
content: result
|
|
97
|
+
}
|
|
98
|
+
messages
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Assemble OS, Ruby, and workspace info for the system prompt
|
|
104
|
+
# @return [String]
|
|
105
|
+
def build_runtime_info
|
|
106
|
+
<<~INFO
|
|
107
|
+
# Runtime Information
|
|
108
|
+
|
|
109
|
+
- OS: #{RbConfig::CONFIG['host_os']}
|
|
110
|
+
- Ruby Version: #{RUBY_VERSION}
|
|
111
|
+
- Platform: #{RUBY_PLATFORM}
|
|
112
|
+
- Workspace: #{@workspace}
|
|
113
|
+
- Current Time: #{Time.now.strftime('%Y-%m-%d %H:%M:%S %Z')}
|
|
114
|
+
INFO
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Read a bootstrap markdown file from the workspace root
|
|
118
|
+
# @param filename [String] name of the file (e.g. 'SOUL.md')
|
|
119
|
+
# @return [String, nil] file content with header, or nil if missing/empty
|
|
120
|
+
def read_bootstrap_file(filename)
|
|
121
|
+
file_path = @workspace / filename
|
|
122
|
+
return nil unless file_path.exist?
|
|
123
|
+
|
|
124
|
+
content = file_path.read.strip
|
|
125
|
+
return nil if content.empty?
|
|
126
|
+
|
|
127
|
+
# Return with header
|
|
128
|
+
"# #{filename.sub('.md', '')}\n\n#{content}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require_relative 'context'
|
|
5
|
+
require_relative '../session/manager'
|
|
6
|
+
require_relative '../bus/events'
|
|
7
|
+
|
|
8
|
+
module Nanobot
|
|
9
|
+
module Agent
|
|
10
|
+
# AgentLoop is the core processing engine that handles message->response pipeline
|
|
11
|
+
class Loop
|
|
12
|
+
attr_reader :bus, :provider, :workspace, :logger
|
|
13
|
+
|
|
14
|
+
# @param bus [Bus::MessageBus] message bus for inbound/outbound messages
|
|
15
|
+
# @param provider [Provider] LLM provider instance
|
|
16
|
+
# @param workspace [String, Pathname] path to the agent workspace directory
|
|
17
|
+
# @param logger [Logger, nil] optional logger instance
|
|
18
|
+
# @param opts [Hash] additional options:
|
|
19
|
+
# :model [String] LLM model override,
|
|
20
|
+
# :max_iterations [Integer] max tool-call loop iterations (default 20),
|
|
21
|
+
# :brave_api_key [String] Brave search API key,
|
|
22
|
+
# :exec_config [Hash] shell exec configuration,
|
|
23
|
+
# :restrict_to_workspace [Boolean] restrict file tools to workspace,
|
|
24
|
+
# :confirm_tool_call [Proc] callback to confirm tool execution,
|
|
25
|
+
# :schedule_store [Scheduler::ScheduleStore] schedule store for scheduling tools
|
|
26
|
+
def initialize(bus:, provider:, workspace:, logger: nil, **opts)
|
|
27
|
+
@bus = bus
|
|
28
|
+
@provider = provider
|
|
29
|
+
@workspace = Pathname.new(workspace).expand_path
|
|
30
|
+
@model = opts[:model] || provider.default_model
|
|
31
|
+
@max_iterations = opts[:max_iterations] || 20
|
|
32
|
+
@brave_api_key = opts[:brave_api_key]
|
|
33
|
+
@exec_config = opts[:exec_config] || {}
|
|
34
|
+
@restrict_to_workspace = opts.fetch(:restrict_to_workspace, true)
|
|
35
|
+
@confirm_tool_call = opts[:confirm_tool_call]
|
|
36
|
+
@schedule_store = opts[:schedule_store]
|
|
37
|
+
@logger = logger || Logger.new(IO::NULL)
|
|
38
|
+
|
|
39
|
+
@context = ContextBuilder.new(@workspace, logger: @logger)
|
|
40
|
+
@sessions = Session::Manager.new(@workspace)
|
|
41
|
+
@running = false
|
|
42
|
+
|
|
43
|
+
register_default_tools
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Start the agent loop (consumes from bus)
|
|
47
|
+
def run
|
|
48
|
+
@running = true
|
|
49
|
+
@logger.info 'Agent loop started'
|
|
50
|
+
|
|
51
|
+
loop do
|
|
52
|
+
break unless @running
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
msg = @bus.consume_inbound(timeout: 1)
|
|
56
|
+
next unless msg
|
|
57
|
+
|
|
58
|
+
response = process_message(msg)
|
|
59
|
+
@bus.publish_outbound(response) if response
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
@logger.error "Error processing message: #{e.message}"
|
|
62
|
+
@logger.error e.backtrace.join("\n")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Stop the agent loop
|
|
68
|
+
def stop
|
|
69
|
+
@running = false
|
|
70
|
+
@logger.info 'Agent loop stopping'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Process a single message directly (for CLI/testing)
|
|
74
|
+
# @param content [String] message content
|
|
75
|
+
# @param channel [String] channel name (default: 'cli')
|
|
76
|
+
# @param chat_id [String] chat ID (default: 'default')
|
|
77
|
+
# @return [String] response content
|
|
78
|
+
def process_direct(content, channel: 'cli', chat_id: 'default')
|
|
79
|
+
msg = Bus::InboundMessage.new(
|
|
80
|
+
channel: channel,
|
|
81
|
+
sender_id: 'user',
|
|
82
|
+
chat_id: chat_id,
|
|
83
|
+
content: content
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
response = process_message(msg)
|
|
87
|
+
response&.content
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Process a message and return a response
|
|
91
|
+
# @param msg [Bus::InboundMessage] inbound message
|
|
92
|
+
# @return [Bus::OutboundMessage, nil] outbound message
|
|
93
|
+
# This method orchestrates message processing and requires complexity
|
|
94
|
+
def process_message(msg)
|
|
95
|
+
@logger.info "Processing message from #{msg.channel}:#{msg.chat_id}"
|
|
96
|
+
|
|
97
|
+
# Handle slash commands before LLM processing
|
|
98
|
+
slash_response = handle_slash_command(msg)
|
|
99
|
+
return slash_response if slash_response
|
|
100
|
+
|
|
101
|
+
# Get or create session
|
|
102
|
+
session = @sessions.get_or_create(msg.session_key)
|
|
103
|
+
|
|
104
|
+
# Build messages for LLM
|
|
105
|
+
messages = @context.build_messages(
|
|
106
|
+
history: session.get_history,
|
|
107
|
+
current_message: msg.content,
|
|
108
|
+
channel: msg.channel,
|
|
109
|
+
chat_id: msg.chat_id
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Save user message to session
|
|
113
|
+
session.add_message('user', msg.content)
|
|
114
|
+
|
|
115
|
+
# Agent loop (also saves intermediate tool call messages to session)
|
|
116
|
+
final_content = agent_loop(messages, session: session, channel: msg.channel)
|
|
117
|
+
|
|
118
|
+
# Save final assistant response to session
|
|
119
|
+
session.add_message('assistant', final_content)
|
|
120
|
+
@sessions.save(session)
|
|
121
|
+
|
|
122
|
+
# Return response
|
|
123
|
+
Bus::OutboundMessage.new(
|
|
124
|
+
channel: msg.channel,
|
|
125
|
+
chat_id: msg.chat_id,
|
|
126
|
+
content: final_content
|
|
127
|
+
)
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
handle_processing_error(msg, e)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Channels considered local (full tool access by default)
|
|
133
|
+
LOCAL_CHANNELS = %w[cli].freeze
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# Build an error response, sanitizing details for remote channels.
|
|
138
|
+
# @param msg [Bus::InboundMessage] the original message
|
|
139
|
+
# @param error [StandardError] the exception that occurred
|
|
140
|
+
# @return [Bus::OutboundMessage]
|
|
141
|
+
def handle_processing_error(msg, error)
|
|
142
|
+
@logger.error "Error in process_message: #{error.message}"
|
|
143
|
+
@logger.error error.backtrace.join("\n")
|
|
144
|
+
|
|
145
|
+
# Don't leak exception details (file paths, credentials, stack traces)
|
|
146
|
+
# to remote channels. Local CLI gets the full message for debugging.
|
|
147
|
+
error_content = if LOCAL_CHANNELS.include?(msg.channel)
|
|
148
|
+
"Sorry, I encountered an error: #{error.message}"
|
|
149
|
+
else
|
|
150
|
+
'Sorry, I encountered an internal error. Check the server logs for details.'
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
Bus::OutboundMessage.new(channel: msg.channel, chat_id: msg.chat_id, content: error_content)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Main agent loop: repeatedly calls LLM and executes tool calls until
|
|
157
|
+
# the LLM returns a final text response or max_iterations is reached
|
|
158
|
+
# @param messages [Array<Hash>] message history in OpenAI format
|
|
159
|
+
# @param session [Session::Session, nil] current session for persistence
|
|
160
|
+
# @param channel [String] channel name for tool scoping
|
|
161
|
+
# @return [String] final response content
|
|
162
|
+
def agent_loop(messages, session: nil, channel: 'cli')
|
|
163
|
+
tools = tools_for_channel(channel)
|
|
164
|
+
iteration = 0
|
|
165
|
+
|
|
166
|
+
while iteration < @max_iterations
|
|
167
|
+
iteration += 1
|
|
168
|
+
@logger.debug "--- Agent loop iteration #{iteration}/#{@max_iterations} ---"
|
|
169
|
+
@logger.debug "Message history size: #{messages.length} messages"
|
|
170
|
+
|
|
171
|
+
response = @provider.chat(messages: messages, tools: tools, model: @model)
|
|
172
|
+
|
|
173
|
+
if response.finish_reason == 'error'
|
|
174
|
+
@logger.error "LLM API error: #{response.content}"
|
|
175
|
+
return "[LLM Error] #{response.content}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
unless response.tool_calls?
|
|
179
|
+
@logger.debug "No tool calls, agent loop complete after #{iteration} iteration(s)"
|
|
180
|
+
return response.content || "I've completed processing."
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
process_tool_calls(response, messages, session, tools)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
@logger.warn "Max iterations (#{@max_iterations}) reached"
|
|
187
|
+
"I've completed processing but reached the maximum iteration limit."
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Append assistant tool-call message and execute each tool call
|
|
191
|
+
# @param response [Provider::Response] LLM response containing tool calls
|
|
192
|
+
# @param messages [Array<Hash>] message history to append to
|
|
193
|
+
# @param session [Session::Session, nil] session for persistence
|
|
194
|
+
# @param tools [Array<RubyLLM::Tool>] tools available for this request
|
|
195
|
+
def process_tool_calls(response, messages, session, tools)
|
|
196
|
+
@logger.debug "Got #{response.tool_calls.length} tool call(s)"
|
|
197
|
+
|
|
198
|
+
payload = serialize_tool_calls(response.tool_calls)
|
|
199
|
+
assistant_msg = { role: 'assistant', content: response.content || '', tool_calls: payload }
|
|
200
|
+
messages << assistant_msg
|
|
201
|
+
session&.add_message('assistant', response.content || '', tool_calls: payload)
|
|
202
|
+
|
|
203
|
+
response.tool_calls.each do |tool_call|
|
|
204
|
+
result_str = execute_tool_call(tool_call, tools)
|
|
205
|
+
messages << { role: 'tool', tool_call_id: tool_call.id, name: tool_call.name, content: result_str }
|
|
206
|
+
session&.add_message('tool', result_str)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Convert tool call objects to OpenAI-compatible hash format
|
|
211
|
+
# @param tool_calls [Array] tool call objects from provider response
|
|
212
|
+
# @return [Array<Hash>] serialized tool calls
|
|
213
|
+
def serialize_tool_calls(tool_calls)
|
|
214
|
+
tool_calls.map do |tc|
|
|
215
|
+
hash = { id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
|
|
216
|
+
hash[:thought_signature] = tc.thought_signature if tc.respond_to?(:thought_signature) && tc.thought_signature
|
|
217
|
+
hash
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Execute a single tool call, checking user confirmation if configured
|
|
222
|
+
# @param tool_call [Object] tool call with #name, #id, and #arguments
|
|
223
|
+
# @param tools [Array<RubyLLM::Tool>] tools available for this request
|
|
224
|
+
# @return [String] tool execution result
|
|
225
|
+
def execute_tool_call(tool_call, tools)
|
|
226
|
+
@logger.debug "Executing tool: #{tool_call.name} id=#{tool_call.id}"
|
|
227
|
+
@logger.debug " Arguments: #{tool_call.arguments}"
|
|
228
|
+
|
|
229
|
+
result_str = if @confirm_tool_call && !@confirm_tool_call.call(tool_call.name, tool_call.arguments)
|
|
230
|
+
'Error: Tool execution was denied by user.'
|
|
231
|
+
else
|
|
232
|
+
run_tool(tool_call, tools)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
@logger.debug " Result (#{result_str.length} chars): #{result_str.slice(0, 1000)}"
|
|
236
|
+
result_str
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Look up and invoke the tool by name from the scoped tool set.
|
|
240
|
+
# Only tools in the provided set can be invoked — this enforces
|
|
241
|
+
# per-channel tool restrictions even if the LLM hallucinates tool names.
|
|
242
|
+
# @param tool_call [Object] tool call with #name and #arguments
|
|
243
|
+
# @param tools [Array<RubyLLM::Tool>] scoped tool set
|
|
244
|
+
# @return [String] tool result or error message
|
|
245
|
+
def run_tool(tool_call, tools)
|
|
246
|
+
tool = tools.find { |t| t.name == tool_call.name }
|
|
247
|
+
return "Error: Tool '#{tool_call.name}' not found" unless tool
|
|
248
|
+
|
|
249
|
+
result = tool.call(tool_call.arguments)
|
|
250
|
+
result.is_a?(String) ? result : result.to_s
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Handle built-in slash commands (/new, /help) before LLM processing
|
|
254
|
+
# @param msg [Bus::InboundMessage]
|
|
255
|
+
# @return [Bus::OutboundMessage, nil] response if command matched, nil otherwise
|
|
256
|
+
def handle_slash_command(msg)
|
|
257
|
+
case msg.content.strip
|
|
258
|
+
when '/new'
|
|
259
|
+
session = @sessions.get_or_create(msg.session_key)
|
|
260
|
+
session.clear
|
|
261
|
+
@sessions.save(session)
|
|
262
|
+
Bus::OutboundMessage.new(channel: msg.channel, chat_id: msg.chat_id,
|
|
263
|
+
content: 'New session started.')
|
|
264
|
+
when '/help'
|
|
265
|
+
Bus::OutboundMessage.new(channel: msg.channel, chat_id: msg.chat_id,
|
|
266
|
+
content: help_text)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @return [String] formatted help text listing available commands
|
|
271
|
+
def help_text
|
|
272
|
+
"Available commands:\n " \
|
|
273
|
+
"/new - Start a new conversation session\n " \
|
|
274
|
+
"/help - Show this help message\n\n" \
|
|
275
|
+
'Send any other message to chat with the AI assistant.'
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Return the tools available for a given channel.
|
|
279
|
+
# Remote channels (Telegram, Discord, Slack, Email, Gateway) get
|
|
280
|
+
# read-only tools by default to limit blast radius from prompt
|
|
281
|
+
# injection or unauthorized senders.
|
|
282
|
+
# @param channel [String] channel name
|
|
283
|
+
# @return [Array<RubyLLM::Tool>] tools available for this channel
|
|
284
|
+
def tools_for_channel(channel)
|
|
285
|
+
if LOCAL_CHANNELS.include?(channel)
|
|
286
|
+
@tool_instances
|
|
287
|
+
else
|
|
288
|
+
@read_only_tools
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Register default tools (RubyLLM-compatible)
|
|
293
|
+
def register_default_tools
|
|
294
|
+
require_relative 'tools/filesystem'
|
|
295
|
+
require_relative 'tools/shell'
|
|
296
|
+
require_relative 'tools/web'
|
|
297
|
+
|
|
298
|
+
allowed_dir = @restrict_to_workspace ? @workspace : nil
|
|
299
|
+
|
|
300
|
+
# Read-only tools (safe for remote channels)
|
|
301
|
+
read_file = Tools::ReadFile.new(allowed_dir: allowed_dir)
|
|
302
|
+
list_dir = Tools::ListDir.new(allowed_dir: allowed_dir)
|
|
303
|
+
web_search = @brave_api_key ? Tools::WebSearch.new(api_key: @brave_api_key) : nil
|
|
304
|
+
web_fetch = Tools::WebFetch.new
|
|
305
|
+
|
|
306
|
+
@read_only_tools = [read_file, list_dir, web_fetch]
|
|
307
|
+
@read_only_tools << web_search if web_search
|
|
308
|
+
|
|
309
|
+
# Write/exec tools (local channels only by default)
|
|
310
|
+
write_file = Tools::WriteFile.new(allowed_dir: allowed_dir)
|
|
311
|
+
edit_file = Tools::EditFile.new(allowed_dir: allowed_dir)
|
|
312
|
+
exec_tool = Tools::Exec.new(
|
|
313
|
+
working_dir: @workspace.to_s,
|
|
314
|
+
timeout: @exec_config[:timeout] || 60,
|
|
315
|
+
restrict_to_workspace: @restrict_to_workspace
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Full tool set
|
|
319
|
+
@tool_instances = @read_only_tools + [write_file, edit_file, exec_tool]
|
|
320
|
+
|
|
321
|
+
if @schedule_store
|
|
322
|
+
require_relative 'tools/schedule'
|
|
323
|
+
schedule_tools = [
|
|
324
|
+
Tools::ScheduleAdd.new(store: @schedule_store),
|
|
325
|
+
Tools::ScheduleList.new(store: @schedule_store),
|
|
326
|
+
Tools::ScheduleRemove.new(store: @schedule_store)
|
|
327
|
+
]
|
|
328
|
+
@tool_instances += schedule_tools
|
|
329
|
+
@read_only_tools << Tools::ScheduleList.new(store: @schedule_store)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
@logger.info "Registered #{@tool_instances.length} tools (#{@read_only_tools.length} read-only)"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Nanobot
|
|
6
|
+
module Agent
|
|
7
|
+
# MemoryStore manages persistent memory for the agent
|
|
8
|
+
# Supports both long-term memory and daily notes
|
|
9
|
+
class MemoryStore
|
|
10
|
+
attr_reader :workspace
|
|
11
|
+
|
|
12
|
+
# @param workspace [String, Pathname] path to the agent workspace directory
|
|
13
|
+
def initialize(workspace)
|
|
14
|
+
@workspace = Pathname.new(workspace).expand_path
|
|
15
|
+
@memory_dir = @workspace / 'memory'
|
|
16
|
+
@memory_dir.mkpath unless @memory_dir.exist?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Read long-term memory
|
|
20
|
+
# @return [String, nil] content of MEMORY.md or nil if not exists
|
|
21
|
+
def read_long_term
|
|
22
|
+
memory_file = @memory_dir / 'MEMORY.md'
|
|
23
|
+
return nil unless memory_file.exist?
|
|
24
|
+
|
|
25
|
+
memory_file.read
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Write to long-term memory
|
|
29
|
+
# @param content [String] content to write
|
|
30
|
+
def write_long_term(content)
|
|
31
|
+
memory_file = @memory_dir / 'MEMORY.md'
|
|
32
|
+
memory_file.write(content)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Append to long-term memory
|
|
36
|
+
# @param content [String] content to append
|
|
37
|
+
def append_long_term(content)
|
|
38
|
+
memory_file = @memory_dir / 'MEMORY.md'
|
|
39
|
+
current = memory_file.exist? ? memory_file.read : ''
|
|
40
|
+
|
|
41
|
+
# Add separator if there's existing content
|
|
42
|
+
separator = current.empty? ? '' : "\n\n"
|
|
43
|
+
memory_file.write(current + separator + content)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Read today's daily note
|
|
47
|
+
# @return [String, nil] content of today's note or nil if not exists
|
|
48
|
+
def read_today
|
|
49
|
+
file = today_file
|
|
50
|
+
return nil unless file.exist?
|
|
51
|
+
|
|
52
|
+
file.read
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Write to today's daily note
|
|
56
|
+
# @param content [String] content to write
|
|
57
|
+
def write_today(content)
|
|
58
|
+
file = today_file
|
|
59
|
+
file.write(content)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Append to today's daily note
|
|
63
|
+
# @param content [String] content to append
|
|
64
|
+
# @param timestamp [Boolean] whether to add timestamp (default: true)
|
|
65
|
+
def append_today(content, timestamp: true)
|
|
66
|
+
file = today_file
|
|
67
|
+
current = file.exist? ? file.read : "# Daily Notes - #{Date.today}\n\n"
|
|
68
|
+
|
|
69
|
+
entry = if timestamp
|
|
70
|
+
"\n## #{Time.now.strftime('%H:%M:%S')}\n\n#{content}"
|
|
71
|
+
else
|
|
72
|
+
"\n#{content}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
today_file.write(current + entry)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get memory context for agent prompts
|
|
79
|
+
# @param include_today [Boolean] whether to include today's notes
|
|
80
|
+
# @return [String] formatted memory context
|
|
81
|
+
def get_memory_context(include_today: true)
|
|
82
|
+
parts = []
|
|
83
|
+
|
|
84
|
+
# Long-term memory
|
|
85
|
+
long_term = read_long_term
|
|
86
|
+
parts << "## Long-term Memory\n\n#{long_term}" if long_term && !long_term.strip.empty?
|
|
87
|
+
|
|
88
|
+
# Today's notes
|
|
89
|
+
if include_today
|
|
90
|
+
today = read_today
|
|
91
|
+
parts << "## Today's Notes\n\n#{today}" if today && !today.strip.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return nil if parts.empty?
|
|
95
|
+
|
|
96
|
+
parts.join("\n\n---\n\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# List all daily notes
|
|
100
|
+
# @return [Array<Hash>] array of {date: Date, path: Pathname}
|
|
101
|
+
def list_daily_notes
|
|
102
|
+
notes = @memory_dir.glob('????-??-??.md').map do |path|
|
|
103
|
+
date_str = path.basename('.md').to_s
|
|
104
|
+
{
|
|
105
|
+
date: Date.parse(date_str),
|
|
106
|
+
path: path
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
notes.sort_by { |n| n[:date] }.reverse
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Read a specific daily note
|
|
113
|
+
# @param date [Date, String] the date to read
|
|
114
|
+
# @return [String, nil]
|
|
115
|
+
def read_daily_note(date)
|
|
116
|
+
date_obj = date.is_a?(Date) ? date : Date.parse(date.to_s)
|
|
117
|
+
note_file = @memory_dir / "#{date_obj.strftime('%Y-%m-%d')}.md"
|
|
118
|
+
|
|
119
|
+
return nil unless note_file.exist?
|
|
120
|
+
|
|
121
|
+
note_file.read
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def today_file
|
|
127
|
+
@memory_dir / "#{Date.today.strftime('%Y-%m-%d')}.md"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|