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.
@@ -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
@@ -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
- CTRL_A = 1
10
- CTRL_E = 5
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
- EOF = [4]
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
- if EOF.include?(char)
37
- :exit
38
- elsif BACKSPACE.include?(char)
39
- backspace
40
- :backspace
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 char == CTRL_E
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
- # wrapped at the given column width. The viewport
90
- # follows the cursor so the cursor line is always
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(cols)
95
- sync_scroll(cols)
96
- text = to_s
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(cols)
108
- sync_scroll(cols)
109
- [(cursor / cols) - @scroll, cursor % cols]
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 sync_scroll(cols)
152
- total_lines = [1, (to_s.length.to_f / cols).ceil].max
153
- cursor_line = cursor / cols
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
@@ -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, "tool: #{tool.name}"]
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, "tool done: #{result.name}"]
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
@@ -75,6 +75,12 @@ class LLM::Repl
75
75
  @offset = [@offset - 1, 0].max
76
76
  end
77
77
 
78
+ ##
79
+ # @return [void]
80
+ def scroll_to_bottom
81
+ @offset = 0
82
+ end
83
+
78
84
  ##
79
85
  # @param [Integer] height
80
86
  # @return [Array<String>]
@@ -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
- cols = columns
103
- rows = input.lines(cols)
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(line)
129
+ Curses.addstr(rows[idx]) if idx < rows.length
108
130
  end
109
- line, col = input.cursor_pos(cols)
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
- @agent = agent
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
- loop do
60
- case input.on_char(window, window.getch)
61
- when :exit then break
62
- when :submit then submit
63
- when :up, :down, :backspace, :char then window.redraw
64
- else
65
- window.redraw
66
- read!
67
- sleep 0.01
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 = input.take
109
- return if text.empty?
110
- status.text = "thinking"
111
- write("user: ", Curses::A_BOLD)
112
- markdown(text)
113
- write("\nagent: ", Curses::A_BOLD)
114
- @thread = Thread.new do
115
- @queue << [:start]
116
- agent.talk(text, tools:, stream:)
117
- @queue << [:done]
118
- rescue => e
119
- @queue << [:error, e]
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
@@ -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