llm.rb 12.3.0 → 12.4.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 +4 -4
- data/CHANGELOG.md +175 -0
- data/README.md +72 -15
- data/data/bedrock.json +159 -0
- data/data/deepinfra.json +5 -5
- data/data/google.json +1 -1
- data/data/openai.json +4 -4
- data/lib/llm/agent.rb +9 -6
- data/lib/llm/context/deserializer.rb +2 -5
- data/lib/llm/repl/command.rb +85 -0
- data/lib/llm/repl/commands/exit.rb +19 -0
- data/lib/llm/repl/input.rb +115 -35
- data/lib/llm/repl/stream.rb +42 -2
- data/lib/llm/repl/transcript.rb +6 -0
- data/lib/llm/repl/window.rb +34 -7
- data/lib/llm/repl.rb +77 -25
- data/lib/llm/tools/ls.rb +30 -0
- data/lib/llm/tools/which.rb +38 -0
- data/lib/llm/version.rb +1 -1
- data/resources/deepdive.md +66 -0
- metadata +5 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Repl::Command LLM::Repl::Command} class is the superclass
|
|
6
|
+
# of all read-eval-print loop commands. A command has a name, and a
|
|
7
|
+
# description. This basic version does not implement parameters. A
|
|
8
|
+
# command is accessible via the `/` prefix: eg `/exit`.
|
|
9
|
+
class Command
|
|
10
|
+
##
|
|
11
|
+
# @api private
|
|
12
|
+
UNDEFINED = Object.new
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# Find a command by a name, or by an input string.
|
|
16
|
+
# @example find by name
|
|
17
|
+
# LLM::Repl::Command.find_by(name: "exit")
|
|
18
|
+
# @example find by input string
|
|
19
|
+
# LLM::Repl::Command.find_by(input: "/exit")
|
|
20
|
+
# @note
|
|
21
|
+
# The input string must be prefixed with "/"
|
|
22
|
+
# or it won't be matched. The match is made
|
|
23
|
+
# against the string before the first space -
|
|
24
|
+
# so "/exit foo" will match the "exit" command
|
|
25
|
+
# but "/exitnow" will not.
|
|
26
|
+
# @param [String] input
|
|
27
|
+
# @param [String] name
|
|
28
|
+
# @return [LLM::Repl::Command, nil]
|
|
29
|
+
def self.find_by(input: nil, name: nil)
|
|
30
|
+
if input
|
|
31
|
+
return nil unless input[0] == "/"
|
|
32
|
+
n, = input.split(" ")
|
|
33
|
+
registry.find { n[1..] == _1.name }
|
|
34
|
+
elsif name
|
|
35
|
+
registry.find { name == _1.name }
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "provide one of: input, name"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# @param [LLM::Repl::Command] command
|
|
43
|
+
# A new subclass
|
|
44
|
+
# @return [void]
|
|
45
|
+
def self.inherited(command)
|
|
46
|
+
LLM.lock(:inherited) do
|
|
47
|
+
registry << command
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# @return [Array<LLM::Repl::Command]
|
|
53
|
+
def self.registry
|
|
54
|
+
@registry ||= []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# Set or get a command name.
|
|
59
|
+
# @param [String] name
|
|
60
|
+
# The command name.
|
|
61
|
+
# @return [String]
|
|
62
|
+
def self.name(name = UNDEFINED)
|
|
63
|
+
return @name if name == UNDEFINED
|
|
64
|
+
@name = name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# Set or get a command description.
|
|
69
|
+
# @param [String] description
|
|
70
|
+
# The command description.
|
|
71
|
+
# @return [String]
|
|
72
|
+
def self.description(description = UNDEFINED)
|
|
73
|
+
return @description if description == UNDEFINED
|
|
74
|
+
@description = description
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# This method should be implemented by subclasses.
|
|
79
|
+
# @raise [NotImplementedError]
|
|
80
|
+
def call(...)
|
|
81
|
+
raise NotImplementedError, "#{self.class}#call is not implemented"
|
|
82
|
+
end
|
|
83
|
+
require_relative "commands/exit"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# The 'exit' command exits the read-eval-print loop
|
|
6
|
+
# by throwing. The {LLM::Repl LLM::Repl} class covers
|
|
7
|
+
# the loop with a catch that gracefully recovers and
|
|
8
|
+
# exits the loop.
|
|
9
|
+
class Command::Exit < Command
|
|
10
|
+
name "exit"
|
|
11
|
+
description "exits the repl"
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# @return [void]
|
|
15
|
+
def call
|
|
16
|
+
throw(:exit)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/llm/repl/input.rb
CHANGED
|
@@ -6,15 +6,37 @@ class LLM::Repl
|
|
|
6
6
|
# the editable input line shown at the bottom of the REPL.
|
|
7
7
|
# @api private
|
|
8
8
|
class Input
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
CTRL = {
|
|
10
|
+
A: Curses::KEY_CTRL_A,
|
|
11
|
+
E: Curses::KEY_CTRL_E,
|
|
12
|
+
F: Curses::KEY_CTRL_F,
|
|
13
|
+
K: Curses::KEY_CTRL_K,
|
|
14
|
+
Y: Curses::KEY_CTRL_Y,
|
|
15
|
+
D: Curses::KEY_CTRL_D
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
UP = Curses::Key::UP
|
|
12
19
|
DOWN = Curses::Key::DOWN
|
|
13
20
|
LEFT = Curses::Key::LEFT
|
|
14
21
|
RIGHT = Curses::Key::RIGHT
|
|
15
22
|
ENTER = [Curses::Key::ENTER, 10, 13]
|
|
16
23
|
BACKSPACE = [Curses::Key::BACKSPACE, 127]
|
|
17
|
-
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# Threshold in seconds. If characters arrive faster than
|
|
27
|
+
# this, we assume the user is pasting multi-line text.
|
|
28
|
+
# Human typing is ~150–300ms per key, so 50ms reliably
|
|
29
|
+
# distinguishes a paste from manual typing.
|
|
30
|
+
PASTE_THRESHOLD = 0.05
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :buffer
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @param [Boolean] bool
|
|
38
|
+
# @return [void]
|
|
39
|
+
attr_writer :paste
|
|
18
40
|
|
|
19
41
|
##
|
|
20
42
|
# @param [LLM::Agent] agent
|
|
@@ -26,44 +48,64 @@ class LLM::Repl
|
|
|
26
48
|
@cursor = 0
|
|
27
49
|
@scroll = 0
|
|
28
50
|
@height = options.fetch(:height, 3)
|
|
51
|
+
@last_char_at = nil
|
|
52
|
+
@paste = false
|
|
29
53
|
end
|
|
30
54
|
|
|
31
55
|
##
|
|
32
56
|
# @param [LLM::Repl::Window] window
|
|
33
57
|
# @param [Object] char
|
|
34
58
|
# @return [Symbol, nil]
|
|
35
|
-
def on_char(window, char)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
elsif ENTER.include?(char)
|
|
42
|
-
:submit
|
|
43
|
-
elsif char == UP
|
|
44
|
-
window.scroll_up
|
|
45
|
-
:up
|
|
46
|
-
elsif char == DOWN
|
|
47
|
-
window.scroll_down
|
|
48
|
-
:down
|
|
49
|
-
elsif char == CTRL_A
|
|
59
|
+
def on_char(window, char, now)
|
|
60
|
+
is_paste = lambda { @last_char_at and (now - @last_char_at) < PASTE_THRESHOLD }
|
|
61
|
+
if CTRL[:D] == char
|
|
62
|
+
delete
|
|
63
|
+
:ctrl_d
|
|
64
|
+
elsif CTRL[:A] == char
|
|
50
65
|
move_start
|
|
51
66
|
:ctrl_a
|
|
52
|
-
elsif
|
|
67
|
+
elsif CTRL[:E] == char
|
|
53
68
|
move_end
|
|
54
69
|
:ctrl_e
|
|
70
|
+
elsif CTRL[:F] == char
|
|
71
|
+
move_forward
|
|
72
|
+
:ctrl_f
|
|
73
|
+
elsif CTRL[:Y] == char
|
|
74
|
+
restore
|
|
75
|
+
:ctrl_y
|
|
76
|
+
elsif CTRL[:K] == char
|
|
77
|
+
kill
|
|
78
|
+
:ctrl_k
|
|
55
79
|
elsif char == LEFT
|
|
56
80
|
move_left
|
|
57
81
|
:left
|
|
58
82
|
elsif char == RIGHT
|
|
59
83
|
move_right
|
|
60
84
|
:right
|
|
85
|
+
elsif BACKSPACE.include?(char)
|
|
86
|
+
backspace
|
|
87
|
+
:backspace
|
|
88
|
+
elsif ENTER.include?(char)
|
|
89
|
+
if @paste = is_paste.()
|
|
90
|
+
insert("\n")
|
|
91
|
+
:char
|
|
92
|
+
else
|
|
93
|
+
:submit
|
|
94
|
+
end
|
|
95
|
+
elsif char == UP
|
|
96
|
+
window.scroll_up
|
|
97
|
+
:up
|
|
98
|
+
elsif char == DOWN
|
|
99
|
+
window.scroll_down
|
|
100
|
+
:down
|
|
61
101
|
elsif String === char
|
|
62
102
|
insert(char)
|
|
63
103
|
:char
|
|
64
104
|
else
|
|
65
105
|
nil
|
|
66
106
|
end
|
|
107
|
+
ensure
|
|
108
|
+
@last_char_at = now if char
|
|
67
109
|
end
|
|
68
110
|
|
|
69
111
|
##
|
|
@@ -86,27 +128,25 @@ class LLM::Repl
|
|
|
86
128
|
|
|
87
129
|
##
|
|
88
130
|
# Returns the visible lines of the input buffer,
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# visible.
|
|
92
|
-
# @param [Integer] cols
|
|
131
|
+
# split by newlines. The viewport follows the cursor
|
|
132
|
+
# so the cursor line is always visible.
|
|
93
133
|
# @return [Array<String>]
|
|
94
|
-
def lines
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
chunks = text.chars.each_slice(cols).map(&:join)
|
|
98
|
-
chunks = [""] if chunks.empty?
|
|
134
|
+
def lines
|
|
135
|
+
scroll!
|
|
136
|
+
chunks = to_s.split("\n", -1)
|
|
99
137
|
chunks[@scroll, height] || []
|
|
100
138
|
end
|
|
101
139
|
|
|
102
140
|
##
|
|
103
141
|
# Returns the cursor position as [line, column] within
|
|
104
142
|
# the visible viewport.
|
|
105
|
-
# @param [Integer] cols
|
|
106
143
|
# @return [Array(Integer, Integer)]
|
|
107
|
-
def cursor_pos
|
|
108
|
-
|
|
109
|
-
|
|
144
|
+
def cursor_pos
|
|
145
|
+
scroll!
|
|
146
|
+
before = to_s[0...cursor]
|
|
147
|
+
line = before.count("\n")
|
|
148
|
+
col = cursor - (before.rindex("\n") || -1) - 1
|
|
149
|
+
[line - @scroll, col]
|
|
110
150
|
end
|
|
111
151
|
|
|
112
152
|
##
|
|
@@ -133,6 +173,35 @@ class LLM::Repl
|
|
|
133
173
|
@cursor = [@cursor + 1, @buffer.size].min
|
|
134
174
|
end
|
|
135
175
|
|
|
176
|
+
##
|
|
177
|
+
# @return [void]
|
|
178
|
+
def move_forward
|
|
179
|
+
@cursor = [0, @cursor + 1].max
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
##
|
|
183
|
+
# @return [void]
|
|
184
|
+
def kill
|
|
185
|
+
@copy = @buffer.slice(@cursor, @buffer.size)
|
|
186
|
+
@buffer[@cursor, @buffer.size] = ""
|
|
187
|
+
@cursor = @buffer.size
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
##
|
|
191
|
+
# @return [void]
|
|
192
|
+
def delete
|
|
193
|
+
@buffer[@cursor] = ""
|
|
194
|
+
@cursor = [0, @cursor].max
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
##
|
|
198
|
+
# @return [void]
|
|
199
|
+
def restore
|
|
200
|
+
return unless @copy
|
|
201
|
+
@buffer.insert(@cursor, @copy)
|
|
202
|
+
@cursor += @copy.size
|
|
203
|
+
end
|
|
204
|
+
|
|
136
205
|
##
|
|
137
206
|
# @return [String]
|
|
138
207
|
def take
|
|
@@ -143,14 +212,20 @@ class LLM::Repl
|
|
|
143
212
|
end
|
|
144
213
|
end
|
|
145
214
|
|
|
215
|
+
##
|
|
216
|
+
# @return [Boolean]
|
|
217
|
+
def paste?
|
|
218
|
+
@paste
|
|
219
|
+
end
|
|
220
|
+
|
|
146
221
|
private
|
|
147
222
|
|
|
148
223
|
##
|
|
149
224
|
# Adjusts @scroll so the cursor line is visible within
|
|
150
225
|
# the viewport.
|
|
151
|
-
def
|
|
152
|
-
total_lines
|
|
153
|
-
cursor_line = cursor
|
|
226
|
+
def scroll!(total_lines = nil)
|
|
227
|
+
total_lines ||= to_s.split("\n", -1).size
|
|
228
|
+
cursor_line = to_s[0...cursor].count("\n")
|
|
154
229
|
if cursor_line < @scroll
|
|
155
230
|
@scroll = cursor_line
|
|
156
231
|
elsif cursor_line >= (@scroll + height)
|
|
@@ -164,8 +239,13 @@ class LLM::Repl
|
|
|
164
239
|
end
|
|
165
240
|
|
|
166
241
|
def insert(char)
|
|
242
|
+
if lines[-1].size >= Curses.cols
|
|
243
|
+
@buffer.insert(@cursor, "\n")
|
|
244
|
+
@cursor += 1
|
|
245
|
+
end
|
|
167
246
|
@buffer.insert(@cursor, char)
|
|
168
247
|
@cursor += char.length
|
|
248
|
+
scroll!
|
|
169
249
|
end
|
|
170
250
|
|
|
171
251
|
def backspace
|
data/lib/llm/repl/stream.rb
CHANGED
|
@@ -34,7 +34,7 @@ class LLM::Repl
|
|
|
34
34
|
if error
|
|
35
35
|
@_queue.push [:status, "tool not found: #{tool.name}"]
|
|
36
36
|
else
|
|
37
|
-
@_queue.push [:status, "
|
|
37
|
+
@_queue.push [:status, "#{tool.name}(#{format_args(tool)})"]
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -43,7 +43,7 @@ class LLM::Repl
|
|
|
43
43
|
# @param [LLM::Function::Return] result
|
|
44
44
|
# @return [void]
|
|
45
45
|
def on_tool_return(_tool, result)
|
|
46
|
-
@_queue.push [:status, "
|
|
46
|
+
@_queue.push [:status, "Thinking"]
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
##
|
|
@@ -52,5 +52,45 @@ class LLM::Repl
|
|
|
52
52
|
def empty!
|
|
53
53
|
@buffer.clear
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Formats tool arguments as compact key: value pairs
|
|
60
|
+
# suitable for the status line. Strings are quoted and
|
|
61
|
+
# truncated, arrays show their first two elements, and
|
|
62
|
+
# hashes collapse to `{…}`. The whole string is capped
|
|
63
|
+
# so it fits alongside the context-usage bar.
|
|
64
|
+
# @param [LLM::Function] tool
|
|
65
|
+
# @param [Integer] max
|
|
66
|
+
# @return [String]
|
|
67
|
+
def format_args(tool, max: 50)
|
|
68
|
+
args = tool.arguments
|
|
69
|
+
pairs = args.to_h.map { "#{_1}: #{format_value(_2)}" }
|
|
70
|
+
result = pairs.join(", ")
|
|
71
|
+
result.size > max ? "#{result[0...max - 1]}…" : result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# @param [Object] value
|
|
76
|
+
# @param [Integer] max
|
|
77
|
+
# @return [String]
|
|
78
|
+
def format_value(value, max: 18)
|
|
79
|
+
case value
|
|
80
|
+
when String
|
|
81
|
+
value.size > max ? "#{value[0...max]}…".inspect : value.inspect
|
|
82
|
+
when Array
|
|
83
|
+
items = value.take(2).map { format_value(_1, max: 10) }
|
|
84
|
+
items << "…" if value.size > 2
|
|
85
|
+
"[#{items.join(", ")}]"
|
|
86
|
+
when Hash
|
|
87
|
+
"{…}"
|
|
88
|
+
when nil
|
|
89
|
+
"nil"
|
|
90
|
+
else
|
|
91
|
+
str = value.inspect
|
|
92
|
+
str.size > max ? "#{str[0...max]}…" : str
|
|
93
|
+
end
|
|
94
|
+
end
|
|
55
95
|
end
|
|
56
96
|
end
|
data/lib/llm/repl/transcript.rb
CHANGED
data/lib/llm/repl/window.rb
CHANGED
|
@@ -46,9 +46,8 @@ class LLM::Repl
|
|
|
46
46
|
##
|
|
47
47
|
# @return [void]
|
|
48
48
|
def redraw
|
|
49
|
-
Curses.clear
|
|
50
|
-
draw_divider(offset: 5)
|
|
51
49
|
draw_status(offset: input.height + 1)
|
|
50
|
+
draw_divider(offset: 5)
|
|
52
51
|
draw_transcript(offset: 0)
|
|
53
52
|
draw_input
|
|
54
53
|
Curses.refresh
|
|
@@ -66,6 +65,24 @@ class LLM::Repl
|
|
|
66
65
|
Curses.getch
|
|
67
66
|
end
|
|
68
67
|
|
|
68
|
+
##
|
|
69
|
+
# Drains all available characters from the terminal input
|
|
70
|
+
# buffer without blocking. Used in place of `Curses.getstr`
|
|
71
|
+
# when the paste flag is set, so that a huge multi-line
|
|
72
|
+
# paste is consumed in a single shot instead of being
|
|
73
|
+
# processed character-by-character.
|
|
74
|
+
# @return [String]
|
|
75
|
+
def read_paste
|
|
76
|
+
chars = +""
|
|
77
|
+
loop do
|
|
78
|
+
ch = Curses.getch
|
|
79
|
+
break unless ch and ch != -1
|
|
80
|
+
chars << ch
|
|
81
|
+
end
|
|
82
|
+
input.paste = false
|
|
83
|
+
chars
|
|
84
|
+
end
|
|
85
|
+
|
|
69
86
|
##
|
|
70
87
|
# @return [void]
|
|
71
88
|
def scroll_up
|
|
@@ -78,6 +95,12 @@ class LLM::Repl
|
|
|
78
95
|
transcript.scroll_down
|
|
79
96
|
end
|
|
80
97
|
|
|
98
|
+
##
|
|
99
|
+
# @return [void]
|
|
100
|
+
def scroll_to_bottom
|
|
101
|
+
transcript.scroll_to_bottom
|
|
102
|
+
end
|
|
103
|
+
|
|
81
104
|
private
|
|
82
105
|
|
|
83
106
|
def draw_status(offset:)
|
|
@@ -99,14 +122,13 @@ class LLM::Repl
|
|
|
99
122
|
end
|
|
100
123
|
|
|
101
124
|
def draw_input
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
rows.each.with_index do |line, idx|
|
|
125
|
+
rows = input.lines
|
|
126
|
+
(0...input.height).each do |idx|
|
|
105
127
|
Curses.setpos((Curses.lines - input.height) + idx, 0)
|
|
106
128
|
Curses.clrtoeol
|
|
107
|
-
Curses.addstr(
|
|
129
|
+
Curses.addstr(rows[idx]) if idx < rows.length
|
|
108
130
|
end
|
|
109
|
-
line, col = input.cursor_pos
|
|
131
|
+
line, col = input.cursor_pos
|
|
110
132
|
Curses.setpos((Curses.lines - input.height) + line, col)
|
|
111
133
|
end
|
|
112
134
|
|
|
@@ -122,6 +144,11 @@ class LLM::Repl
|
|
|
122
144
|
Curses.attroff(attrs) if attrs
|
|
123
145
|
end
|
|
124
146
|
end
|
|
147
|
+
last_drawn = offset + rows.size
|
|
148
|
+
(last_drawn...self.rows).each do |line|
|
|
149
|
+
Curses.setpos(line, 0)
|
|
150
|
+
Curses.clrtoeol
|
|
151
|
+
end
|
|
125
152
|
end
|
|
126
153
|
|
|
127
154
|
##
|
data/lib/llm/repl.rb
CHANGED
|
@@ -22,16 +22,20 @@ module LLM
|
|
|
22
22
|
require_relative "repl/bar"
|
|
23
23
|
require_relative "repl/stream"
|
|
24
24
|
require_relative "repl/markdown"
|
|
25
|
+
require_relative "repl/command"
|
|
25
26
|
|
|
26
27
|
##
|
|
27
28
|
# @param [LLM::Agent] agent
|
|
29
|
+
# @param [String, nil] path
|
|
30
|
+
# The path where to maintain runtime state
|
|
28
31
|
# @param [Array<LLM::Tool>] tools
|
|
29
32
|
# Zero or more tools
|
|
30
33
|
# @param [Array<String>] skills
|
|
31
34
|
# Zero or more skills
|
|
32
35
|
# @return [LLM::Repl]
|
|
33
|
-
def initialize(agent:, tools:, skills:)
|
|
34
|
-
@
|
|
36
|
+
def initialize(agent:, tools:, skills:, path:)
|
|
37
|
+
@path = path
|
|
38
|
+
@agent = configure(agent:, path:)
|
|
35
39
|
@provider = agent.llm.name
|
|
36
40
|
@status = Status.new(@agent)
|
|
37
41
|
@transcript = Transcript.new
|
|
@@ -56,15 +60,17 @@ module LLM
|
|
|
56
60
|
# @return [void]
|
|
57
61
|
def start
|
|
58
62
|
window.open do
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
catch(:exit) do
|
|
64
|
+
loop do
|
|
65
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
case input.on_char(window, input.paste? ? window.read_paste : window.getch, now)
|
|
67
|
+
when :submit then submit
|
|
68
|
+
when Symbol then window.redraw
|
|
69
|
+
else
|
|
70
|
+
window.redraw
|
|
71
|
+
read!
|
|
72
|
+
sleep 0.01
|
|
73
|
+
end
|
|
68
74
|
end
|
|
69
75
|
end
|
|
70
76
|
end
|
|
@@ -97,6 +103,25 @@ module LLM
|
|
|
97
103
|
|
|
98
104
|
private
|
|
99
105
|
|
|
106
|
+
##
|
|
107
|
+
# @param [LLM::Agent] agent
|
|
108
|
+
# An agent
|
|
109
|
+
# @param [String] path
|
|
110
|
+
# A path
|
|
111
|
+
# @raise [LLM::Error]
|
|
112
|
+
# When given an unusable path
|
|
113
|
+
# @return [LLM::Agent]
|
|
114
|
+
def configure(agent:, path:)
|
|
115
|
+
if path.nil? or !File.exist?(path)
|
|
116
|
+
agent
|
|
117
|
+
elsif path?
|
|
118
|
+
agent.restore(path:)
|
|
119
|
+
else
|
|
120
|
+
raise LLM::Error, "I can't use '#{path}' - " \
|
|
121
|
+
"it should be both readable and writable"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
100
125
|
##
|
|
101
126
|
# This method is called when the user submits their input.
|
|
102
127
|
# It spawns a second thread that maintains a line of
|
|
@@ -104,22 +129,38 @@ module LLM
|
|
|
104
129
|
# the UI runs - remains responsive.
|
|
105
130
|
# @api private
|
|
106
131
|
def submit
|
|
107
|
-
return if thread&.alive?
|
|
108
|
-
text
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
return if thread&.alive? || (text = input.take).empty?
|
|
133
|
+
case on_text(text)
|
|
134
|
+
in [:command, Command => command]
|
|
135
|
+
command.call
|
|
136
|
+
in [:input, String => text]
|
|
137
|
+
window.scroll_to_bottom
|
|
138
|
+
status.text = "thinking"
|
|
139
|
+
write("user: ", Curses::A_BOLD)
|
|
140
|
+
markdown(text)
|
|
141
|
+
write("\nagent: ", Curses::A_BOLD)
|
|
142
|
+
@thread = Thread.new do
|
|
143
|
+
@queue << [:start]
|
|
144
|
+
agent.talk(text, tools:, stream:)
|
|
145
|
+
agent.save(path:) if path?
|
|
146
|
+
@queue << [:done]
|
|
147
|
+
rescue => e
|
|
148
|
+
@queue << [:error, e]
|
|
149
|
+
end
|
|
120
150
|
end
|
|
121
151
|
end
|
|
122
152
|
|
|
153
|
+
##
|
|
154
|
+
# Receives user input and determines what codepath
|
|
155
|
+
# it should follow - either executing a command,
|
|
156
|
+
# or sending a string to the model.
|
|
157
|
+
# @param [String] text
|
|
158
|
+
# @return [[Symbol, Command|String]]
|
|
159
|
+
def on_text(text)
|
|
160
|
+
command = Command.find_by(input: text)
|
|
161
|
+
command ? [:command, command.new] : [:input, text]
|
|
162
|
+
end
|
|
163
|
+
|
|
123
164
|
##
|
|
124
165
|
# This method reads from the queue that is written to
|
|
125
166
|
# by a second thread. The queue is managed or written
|
|
@@ -152,8 +193,19 @@ module LLM
|
|
|
152
193
|
rescue ThreadError
|
|
153
194
|
end
|
|
154
195
|
|
|
196
|
+
##
|
|
197
|
+
# @return [Boolean]
|
|
198
|
+
def path?
|
|
199
|
+
return false if path.nil?
|
|
200
|
+
File.readable?(path) and File.writable?(path)
|
|
201
|
+
end
|
|
202
|
+
|
|
155
203
|
attr_reader :agent, :provider, :stream,
|
|
156
204
|
:status, :transcript, :input,
|
|
157
|
-
:window, :tools, :thread
|
|
205
|
+
:window, :tools, :thread,
|
|
206
|
+
:path
|
|
207
|
+
|
|
208
|
+
File = ::File
|
|
209
|
+
private_constant :File
|
|
158
210
|
end
|
|
159
211
|
end
|
data/lib/llm/tools/ls.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Tool
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Tool::Ls LLM::Tool::Ls} class implements
|
|
6
|
+
# a tool that can list files and directories, with an
|
|
7
|
+
# optional glob pattern to filter results.
|
|
8
|
+
class Ls < self
|
|
9
|
+
name "ls"
|
|
10
|
+
description "list files and directories, optionally matching a glob pattern"
|
|
11
|
+
parameter :path, String, "the directory to list (default is cwd)"
|
|
12
|
+
parameter :glob, String, "an optional glob pattern (e.g. '*.rb', '**/*.md')"
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param [String] path
|
|
16
|
+
# @param [String, nil] glob
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def call(path: Dir.getwd, glob: "*")
|
|
19
|
+
validate!(path:)
|
|
20
|
+
entries = Dir.glob(File.join(path, glob))
|
|
21
|
+
{ok: true, entries:, count: entries.size}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate!(path:)
|
|
27
|
+
raise "path does not exist: #{path}" unless Dir.exist?(path)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|