llm.rb 12.6.0 → 13.0.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +387 -0
  3. data/LICENSE +21 -93
  4. data/README.md +46 -155
  5. data/data/deepinfra.json +3 -0
  6. data/data/xai.json +1 -1
  7. data/lib/llm/a2a.rb +1 -1
  8. data/lib/llm/active_record/acts_as_llm.rb +6 -6
  9. data/lib/llm/agent.rb +62 -23
  10. data/lib/llm/buffer.rb +85 -3
  11. data/lib/llm/compactor/null.rb +19 -0
  12. data/lib/llm/compactor/truncate.rb +80 -0
  13. data/lib/llm/compactor.rb +42 -124
  14. data/lib/llm/context.rb +31 -37
  15. data/lib/llm/contract.rb +4 -25
  16. data/lib/llm/function/array.rb +15 -14
  17. data/lib/llm/function/async/group.rb +54 -0
  18. data/lib/llm/function/async/reactor.rb +48 -0
  19. data/lib/llm/function/async/task.rb +83 -0
  20. data/lib/llm/function/fiber/group.rb +46 -0
  21. data/lib/llm/function/fiber/task.rb +62 -0
  22. data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
  23. data/lib/llm/function/fork/job.rb +2 -2
  24. data/lib/llm/function/fork/task.rb +19 -10
  25. data/lib/llm/function/group.rb +40 -0
  26. data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
  27. data/lib/llm/function/ractor/job.rb +9 -3
  28. data/lib/llm/function/ractor/mailbox.rb +2 -0
  29. data/lib/llm/function/ractor/task.rb +23 -15
  30. data/lib/llm/function/{call_group.rb → sequential/group.rb} +12 -8
  31. data/lib/llm/function/sequential/task.rb +49 -0
  32. data/lib/llm/function/task.rb +25 -48
  33. data/lib/llm/function/thread/group.rb +46 -0
  34. data/lib/llm/function/thread/task.rb +60 -0
  35. data/lib/llm/function.rb +54 -64
  36. data/lib/llm/loop_guard.rb +1 -2
  37. data/lib/llm/mcp.rb +22 -0
  38. data/lib/llm/object.rb +2 -1
  39. data/lib/llm/provider.rb +6 -3
  40. data/lib/llm/providers/google.rb +2 -2
  41. data/lib/llm/repl/command.rb +35 -8
  42. data/lib/llm/repl/commands/compact.rb +33 -0
  43. data/lib/llm/repl/input.rb +80 -15
  44. data/lib/llm/repl/markdown/table.rb +76 -0
  45. data/lib/llm/repl/markdown.rb +31 -1
  46. data/lib/llm/repl/status.rb +1 -1
  47. data/lib/llm/repl/stream.rb +10 -3
  48. data/lib/llm/repl/transcript.rb +1 -1
  49. data/lib/llm/repl/walker.rb +46 -0
  50. data/lib/llm/repl.rb +18 -12
  51. data/lib/llm/response.rb +10 -0
  52. data/lib/llm/schema/leaf.rb +5 -0
  53. data/lib/llm/schema/object.rb +11 -5
  54. data/lib/llm/sequel/plugin.rb +6 -6
  55. data/lib/llm/stream.rb +24 -17
  56. data/lib/llm/tool.rb +20 -4
  57. data/lib/llm/tools/chdir.rb +0 -2
  58. data/lib/llm/tools/git.rb +8 -4
  59. data/lib/llm/tools/mkdir.rb +1 -1
  60. data/lib/llm/tools/pwd.rb +0 -2
  61. data/lib/llm/tools/read_file.rb +0 -2
  62. data/lib/llm/tools/rg.rb +8 -4
  63. data/lib/llm/tools/shell.rb +8 -4
  64. data/lib/llm/tools/utils.rb +31 -0
  65. data/lib/llm/version.rb +1 -1
  66. data/lib/llm.rb +25 -5
  67. data/llm.gemspec +3 -3
  68. data/resources/deepdive.md +645 -58
  69. metadata +24 -13
  70. data/lib/llm/function/call_task.rb +0 -46
  71. data/lib/llm/function/fiber_group.rb +0 -105
  72. data/lib/llm/function/task_group.rb +0 -97
  73. data/lib/llm/function/thread_group.rb +0 -102
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Function::Thread
4
+ ##
5
+ # Wraps an array of {Thread::Task} objects for concurrent
6
+ # thread-based execution. Interrupts all tasks and waits
7
+ # for them to complete.
8
+ class Group < LLM::Function::Group
9
+ ##
10
+ # @param [Array<LLM::Function::Thread::Task>] tasks
11
+ def initialize(tasks)
12
+ @tasks = tasks
13
+ end
14
+
15
+ ##
16
+ # @return [nil]
17
+ def spawn
18
+ @tasks.each(&:spawn)
19
+ nil
20
+ ensure
21
+ @spawned = true
22
+ end
23
+
24
+ ##
25
+ # @return [Boolean]
26
+ def alive?
27
+ @tasks.any?(&:alive?)
28
+ end
29
+
30
+ ##
31
+ # @return [nil]
32
+ def interrupt!
33
+ @tasks.each(&:interrupt!)
34
+ nil
35
+ end
36
+ alias_method :cancel!, :interrupt!
37
+
38
+ ##
39
+ # @return [Array<LLM::Function::Return>]
40
+ def wait
41
+ spawn unless @spawned
42
+ @tasks.map(&:wait)
43
+ end
44
+ alias_method :value, :wait
45
+ end
46
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Function::Thread
4
+ ##
5
+ # {LLM::Function::Thread::Task LLM::Function::Thread::Task}
6
+ # wraps a function call in a background thread for concurrent
7
+ # tool execution. The thread is created lazily when {#wait} is
8
+ # called, not when the task is constructed — so you can build
9
+ # a task, pass it around, and decide when to run it.
10
+ #
11
+ # Interrupting a running task raises {LLM::Interrupt} inside
12
+ # the thread, which stops the tool call mid-flight. The thread
13
+ # is created with `report_on_exception` disabled so unhandled
14
+ # exceptions propagate through {#wait} instead of to stderr.
15
+ class Task < LLM::Function::Task
16
+ ##
17
+ # @param [LLM::Function] fn
18
+ # @param [Hash] options
19
+ def initialize(fn, options = {})
20
+ super
21
+ end
22
+
23
+ ##
24
+ # @return [nil]
25
+ def spawn
26
+ @thread = ::Thread.new { function.call }
27
+ @thread.report_on_exception = false
28
+ nil
29
+ end
30
+
31
+ ##
32
+ # @return [Boolean]
33
+ def alive?
34
+ @thread&.alive? || false
35
+ end
36
+
37
+ ##
38
+ # @return [nil]
39
+ def interrupt!
40
+ @thread&.raise(LLM::Interrupt) if @thread&.alive?
41
+ function.interrupt!
42
+ nil
43
+ end
44
+ alias_method :cancel!, :interrupt!
45
+
46
+ ##
47
+ # @return [LLM::Function::Return]
48
+ def wait
49
+ spawn unless @thread
50
+ @thread.value
51
+ end
52
+ alias_method :value, :wait
53
+
54
+ ##
55
+ # @return [Class]
56
+ def group_class
57
+ LLM::Function::Thread::Group
58
+ end
59
+ end
60
+ end
data/lib/llm/function.rb CHANGED
@@ -2,29 +2,30 @@
2
2
 
3
3
  ##
4
4
  # The {LLM::Function LLM::Function} class represents a local
5
- # function that can be called by an LLM.
5
+ # function that can be called by an LLM. Most users should define
6
+ # tools as subclasses of {LLM::Tool} instead — Function is the
7
+ # lower-level building block that Tool wraps.
6
8
  #
7
- # @example example #1
8
- # LLM.function(:system) do |fn|
9
- # fn.name "system"
10
- # fn.description "Runs system commands"
11
- # fn.params do |schema|
12
- # schema.object(command: schema.string.required)
13
- # end
14
- # fn.define do |command:|
15
- # {success: Kernel.system(command)}
9
+ # @example Tool subclass (preferred for most users)
10
+ # class ReadFile < LLM::Tool
11
+ # name "read-file"
12
+ # description "Read a file from disk"
13
+ # parameter :path, String, "The filename or path"
14
+ # required %i[path]
15
+ #
16
+ # def call(path:)
17
+ # {contents: File.read(path)}
16
18
  # end
17
19
  # end
18
20
  #
19
- # @example example #2
20
- # class System < LLM::Tool
21
- # name "system"
22
- # description "Runs system commands"
23
- # params do |schema|
21
+ # @example Inline function (block-form DSL)
22
+ # LLM.function(:run_command) do |fn|
23
+ # fn.name "run-command"
24
+ # fn.description "Runs a shell command"
25
+ # fn.params do |schema|
24
26
  # schema.object(command: schema.string.required)
25
27
  # end
26
- #
27
- # def call(command:)
28
+ # fn.define do |command:|
28
29
  # {success: Kernel.system(command)}
29
30
  # end
30
31
  # end
@@ -32,16 +33,21 @@ class LLM::Function
32
33
  require_relative "function/registry"
33
34
  require_relative "function/tracing"
34
35
  require_relative "function/array"
35
- require_relative "function/call_group"
36
- require_relative "function/call_task"
36
+ require_relative "function/group"
37
+ require_relative "function/sequential/group"
37
38
  require_relative "function/task"
38
- require_relative "function/thread_group"
39
- require_relative "function/fiber_group"
40
- require_relative "function/task_group"
39
+ require_relative "function/sequential/task"
40
+ require_relative "function/thread/task"
41
+ require_relative "function/fiber/task"
42
+ require_relative "function/async/reactor"
43
+ require_relative "function/async/task"
44
+ require_relative "function/thread/group"
45
+ require_relative "function/fiber/group"
46
+ require_relative "function/async/group"
41
47
  require_relative "function/fork"
42
- require_relative "function/fork_group"
48
+ require_relative "function/fork/group"
43
49
  require_relative "function/ractor"
44
- require_relative "function/ractor_group"
50
+ require_relative "function/ractor/group"
45
51
 
46
52
  extend LLM::Function::Registry
47
53
  prepend LLM::Function::Tracing
@@ -213,70 +219,60 @@ class LLM::Function
213
219
 
214
220
  ##
215
221
  # Call the function
216
- # @return [LLM::Function::Return] The result of the function call
222
+ # @return [LLM::Function::Return]
217
223
  def call
218
- call_function
224
+ llm = @tracer&.llm
225
+ llm ? llm.with_tracer(@tracer) { call_function } : call_function
219
226
  ensure
220
227
  @called = true
221
228
  end
222
229
 
230
+
223
231
  ##
224
- # Calls the function concurrently.
225
- #
226
- # This is the low-level method that powers concurrent tool execution.
227
- # Prefer the collection methods on {LLM::Context#functions} for most
228
- # use cases: {LLM::Function::Array#call}, {LLM::Function::Array#wait},
229
- # or {LLM::Function::Array#spawn}.
232
+ # Returns a function as a {LLM::Function::Task LLM::Function::Task}.
230
233
  #
231
234
  # @example
232
- # # Normal usage (via collection)
233
- # ctx.talk(ctx.functions.wait)
235
+ # # As a group
236
+ # ctx.talk(ctx.pending_functions.wait)
234
237
  #
235
- # # Direct usage (uncommon)
236
- # task = tool.spawn(:thread)
238
+ # # As a task
239
+ # task = tool.task(:thread)
237
240
  # result = task.value
238
241
  #
239
242
  # @param [Symbol] strategy
240
243
  # Controls concurrency strategy:
241
- # - `:call`: Call the function sequentially without spawning
244
+ # - `:sequential`: Call the function sequentially
242
245
  # - `:thread`: Use threads
243
- # - `:task`: Use async tasks (requires async gem)
246
+ # - `:async`: Use async tasks (requires async gem)
244
247
  # - `:fork`: Use a forked child process (requires xchan.rb support)
245
248
  # - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
246
- # - `:fork`: Use a forked child process (requires xchan.rb support)
247
249
  # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
248
250
  #
249
251
  # @return [LLM::Function::Task]
250
252
  # Returns a task whose `#value` is an {LLM::Function::Return}.
251
- def spawn(strategy)
252
- task = case strategy
253
- when :call
254
- CallTask.new(self)
255
- when :task
253
+ def task(strategy, options = {})
254
+ case strategy
255
+ when :sequential
256
+ Sequential::Task.new(self, options)
257
+ when :async
256
258
  LLM.require "async" unless defined?(::Async)
257
- Async { call! }
259
+ Async::Task.new(self, options)
258
260
  when :thread
259
- Thread.new { call! }.tap { _1.report_on_exception = false }
261
+ Thread::Task.new(self, options)
260
262
  when :fiber
261
- raise ArgumentError, "Fiber concurrency requires Fiber.scheduler" unless Fiber.scheduler
262
- Fiber.schedule { call! }
263
+ Fiber::Task.new(self, options)
263
264
  when :fork
264
- LLM.require "xchan" unless defined?(::Chan::UNIXSocket)
265
- span = @tracer&.on_tool_start(id:, name:, arguments:, model:)
266
- Fork::Task.new(self, tracer: @tracer, span:).spawn
265
+ LLM.require "xchan", "~> 0.22" unless defined?(::Chan::UNIXSocket)
266
+ Fork::Task.new(self, options.merge(tracer: @tracer))
267
267
  when :ractor
268
268
  raise LLM::RactorError, "Ractor concurrency only supports class-based tools" unless Class === @runner
269
269
  if @runner.respond_to?(:skill?) && @runner.skill?
270
270
  raise LLM::RactorError, "Ractor concurrency does not support skill-backed tools"
271
271
  end
272
- span = @tracer&.on_tool_start(id:, name:, arguments:, model:)
273
- Ractor::Task.new(@runner, id, name, arguments, tracer: @tracer, span:).spawn
272
+ Ractor::Task.new(self, options.merge(runner_class: @runner, id:, name:, arguments:, tracer: @tracer, model:))
274
273
  else
275
- raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :call, :thread, :task, :fiber, :fork, or :ractor"
274
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :sequential, :thread, :fiber, :async, :fork, or :ractor"
276
275
  end
277
- Task.new(task, self)
278
- ensure
279
- @called = true
280
276
  end
281
277
 
282
278
  ##
@@ -285,7 +281,7 @@ class LLM::Function
285
281
  # llm = LLM.openai(key: ENV["KEY"])
286
282
  # ctx = LLM::Context.new(llm, tools: [fn1, fn2])
287
283
  # ctx.talk "I want to run the functions"
288
- # ctx.talk ctx.functions.map(&:cancel)
284
+ # ctx.talk ctx.pending_functions.map(&:cancel)
289
285
  # @return [LLM::Function::Return]
290
286
  def cancel(reason: "function call cancelled")
291
287
  Return.new(id, name, {cancelled: true, reason:})
@@ -387,10 +383,4 @@ class LLM::Function
387
383
  rescue => ex
388
384
  Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
389
385
  end
390
-
391
- def call!
392
- llm = @tracer&.llm
393
- return call unless llm.respond_to?(:with_tracer)
394
- llm.with_tracer(@tracer) { call }
395
- end
396
386
  end
@@ -9,8 +9,7 @@
9
9
  # should be blocked before the loop keeps going.
10
10
  #
11
11
  # {LLM::LoopGuard LLM::LoopGuard} detects when a context is repeating the same
12
- # tool-call pattern instead of making progress. It is directly inspired by
13
- # General Intelligence Systems and its doom-loop detection approach.
12
+ # tool-call pattern instead of making progress.
14
13
  #
15
14
  # The public interface is intentionally small:
16
15
  # - `call(ctx)` returns `nil` when no intervention is needed
data/lib/llm/mcp.rb CHANGED
@@ -13,6 +13,28 @@
13
13
  # An MCP client is stateful. Coordinate lifecycle operations such as
14
14
  # {#start} and {#stop}; request methods can be issued concurrently and
15
15
  # responses are matched by JSON-RPC id.
16
+ #
17
+ # @example stdio transport
18
+ # llm = LLM.deepseek(key: ENV["KEY"])
19
+ # mcp = LLM::MCP.stdio(argv: ["npx", "-y", "@forgejo/mcp-server"])
20
+ # agent = LLM::Agent.new(llm)
21
+ #
22
+ # # Preferred: session keeps one process alive across multiple calls
23
+ # mcp.session do
24
+ # agent.talk "What's happening on forgejo?", tools: mcp.tools
25
+ # end
26
+ #
27
+ # # Also works: one-shot, spawns a new process per call
28
+ # agent.talk "What's happening on forgejo?", tools: mcp.tools
29
+ #
30
+ # @example HTTP transport
31
+ # mcp = LLM::MCP.http(
32
+ # url: "https://api.githubcopilot.com/mcp/",
33
+ # headers: {"Authorization" => "Bearer #{ENV.fetch('GITHUB_PAT')}"},
34
+ # transport: :net_http_persistent
35
+ # )
36
+ # agent = LLM::Agent.new(llm)
37
+ # agent.talk "What's happening on GitHub?", tools: mcp.tools
16
38
  class LLM::MCP
17
39
  require_relative "mcp/error"
18
40
  require_relative "mcp/command"
data/lib/llm/object.rb CHANGED
@@ -133,7 +133,8 @@ class LLM::Object < BasicObject
133
133
  # @return [Object]
134
134
  def fetch(k = UNDEFINED, *args, &b)
135
135
  return SINGLETON.get(@h, :fetch) if k.equal?(UNDEFINED)
136
- @h.fetch(SINGLETON.key(@h, k), *args, &b)
136
+ key = SINGLETON.key(@h, k)
137
+ @h.fetch(key || k, *args, &b)
137
138
  end
138
139
 
139
140
  ##
data/lib/llm/provider.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ##
4
- # The Provider class represents an abstract class for
5
- # LLM (Language Model) providers.
4
+ # The Provider class is the abstract base for LLM service integrations.
5
+ # Most users interact with providers through {LLM::Agent} or
6
+ # {LLM::Context} rather than calling {#complete} directly.
6
7
  #
7
8
  # @abstract
8
9
  class LLM::Provider
@@ -82,7 +83,9 @@ class LLM::Provider
82
83
  end
83
84
 
84
85
  ##
85
- # Provides an interface to the chat completions API
86
+ # Provides an interface to the chat completions API.
87
+ # Most users should use {LLM::Context#talk} or {LLM::Agent#talk} instead.
88
+ #
86
89
  # @example
87
90
  # llm = LLM.openai(key: ENV["KEY"])
88
91
  # messages = [{role: "system", content: "Your task is to answer all of my questions"}]
@@ -209,11 +209,11 @@ module LLM
209
209
  role, model, stream = params.delete(:role),
210
210
  params.delete(:model),
211
211
  LLM::Stream.try(params.delete(:stream))
212
- [params.merge!(stream: stream.enabled?), stream, tools, role, model]
212
+ [params, stream, tools, role, model]
213
213
  end
214
214
 
215
215
  def build_complete_request(prompt, params, role, model, stream)
216
- action = stream ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
216
+ action = stream.enabled? ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
217
217
  model.respond_to?(:id) ? model.id : model
218
218
  path = ["/v1beta/models/#{model}", action].join(":")
219
219
  req = LLM::Transport::Request.post(path, headers)
@@ -11,6 +11,17 @@ class LLM::Repl
11
11
  SINGLETON = self
12
12
  private_constant :UNDEFINED, :SINGLETON
13
13
 
14
+ ##
15
+ # @param [String] str
16
+ # An input string
17
+ # @return [Array<String>]
18
+ # An array of command names who match the input string
19
+ def self.complete(str)
20
+ registry.keys.select do |name|
21
+ name.start_with?(str[1..])
22
+ end
23
+ end
24
+
14
25
  ##
15
26
  # @api private
16
27
  Parameter = Struct.new(:name, :type, :description, :options, :index, :value) do
@@ -62,31 +73,37 @@ class LLM::Repl
62
73
  if input != UNDEFINED
63
74
  return nil unless input[0] == "/"
64
75
  n, = input.split(" ")
65
- registry.find { n[1..] == _1.name }
76
+ registry.values.find { n[1..] == _1.name }
66
77
  elsif name != UNDEFINED
67
- registry.find { name == _1.name }
78
+ registry.values.find { name == _1.name }
68
79
  else
69
80
  raise ArgumentError, "provide either an input or a name"
70
81
  end
71
82
  end
72
83
 
73
84
  ##
74
- # @param [LLM::Repl::Command] command
85
+ # @param [LLM::Repl::Command] outer
75
86
  # A new subclass
76
87
  # @return [void]
77
- def self.inherited(command)
88
+ def self.inherited(outer)
78
89
  LLM.lock(:inherited) do
79
- registry << command
80
- command.instance_variable_set(:@parameters, {})
81
- command.define_singleton_method(:inherited) { |command| SINGLETON.inherited(command) }
90
+ @registry[outer] = outer
91
+ outer.instance_variable_set(:@parameters, {})
92
+ outer.define_singleton_method(:inherited) do |inner|
93
+ SINGLETON.inherited(inner)
94
+ inner.instance_variable_set(:@name, outer.instance_variable_get(:@name))
95
+ inner.instance_variable_set(:@description, outer.instance_variable_get(:@description))
96
+ inner.instance_variable_set(:@parameters, outer.instance_variable_get(:@parameters))
97
+ end
82
98
  end
83
99
  end
84
100
 
85
101
  ##
86
102
  # @return [Array<LLM::Repl::Command]
87
103
  def self.registry
88
- @registry ||= []
104
+ @registry.transform_keys(&:name)
89
105
  end
106
+ @registry = {}
90
107
 
91
108
  ##
92
109
  # Set or get a command name.
@@ -142,11 +159,20 @@ class LLM::Repl
142
159
  end
143
160
  end
144
161
 
162
+ ##
163
+ # @return [LLM::Repl]
164
+ attr_reader :repl
165
+
166
+ ##
167
+ # @return [LLM::Agent]
168
+ attr_reader :agent
169
+
145
170
  ##
146
171
  # @param [LLM::Repl] repl
147
172
  # @return [LLM::Repl::Command]
148
173
  def initialize(repl)
149
174
  @repl = repl
175
+ @agent = repl.agent
150
176
  end
151
177
 
152
178
  ##
@@ -191,6 +217,7 @@ class LLM::Repl
191
217
  self.class.parameters
192
218
  end
193
219
 
220
+ require_relative "commands/compact"
194
221
  require_relative "commands/exit"
195
222
  require_relative "commands/help"
196
223
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The 'compact' command frees space in the
6
+ # context window and llm.rb is designed to
7
+ # support multiple compaction strategies with
8
+ # different trade offs. This command, though,
9
+ # uses the 'truncate' strategy. See
10
+ # {LLM::Compactor::Truncate LLM::Compactor::Truncate}
11
+ # for more details.
12
+ class Command::Compact < Command
13
+ name "compact"
14
+ description "frees space in the context window"
15
+ parameter :n, String, "the number of messages to keep"
16
+
17
+ ##
18
+ # @return [void]
19
+ def call(n: 128)
20
+ write("compact in progress\n")
21
+ compactor.call(keep: n)
22
+ write("compact complete\n\n")
23
+ end
24
+
25
+ private
26
+
27
+ ##
28
+ # @return [LLM::Compactor::Truncate]
29
+ def compactor
30
+ @compactor ||= LLM::Compactor::Truncate.new(agent)
31
+ end
32
+ end
33
+ end
@@ -12,16 +12,30 @@ class LLM::Repl
12
12
  F: Curses::KEY_CTRL_F,
13
13
  K: Curses::KEY_CTRL_K,
14
14
  Y: Curses::KEY_CTRL_Y,
15
- D: Curses::KEY_CTRL_D
15
+ D: Curses::KEY_CTRL_D,
16
+ P: Curses::KEY_CTRL_P,
17
+ N: Curses::KEY_CTRL_N
16
18
  }
17
19
 
20
+ ##
21
+ # This hash tracks how many times a given key
22
+ # was pressed repeatedly without being
23
+ # interrupted by another key. The previous key
24
+ # is reset to 0 when a different key is pressed.
25
+ REPEATS = {}
26
+ REPEATS.default = 0
27
+
18
28
  UP = Curses::Key::UP
19
29
  DOWN = Curses::Key::DOWN
20
30
  LEFT = Curses::Key::LEFT
21
31
  RIGHT = Curses::Key::RIGHT
32
+ PGUP = Curses::KEY_PPAGE
33
+ PGDOWN = Curses::KEY_NPAGE
34
+
35
+ TAB = 9
22
36
  ESC = 27
23
- ENTER = [Curses::Key::ENTER, 10, 13]
24
- BACKSPACE = [Curses::Key::BACKSPACE, 127]
37
+ ENTER = 10
38
+ BACKSPACE = 127
25
39
 
26
40
  ##
27
41
  # Threshold in seconds. If characters arrive faster than
@@ -35,21 +49,24 @@ class LLM::Repl
35
49
  attr_reader :buffer
36
50
 
37
51
  ##
38
- # @param [Boolean] bool
52
+ # @param [Boolean] value
39
53
  # @return [void]
40
54
  attr_writer :paste
41
55
 
42
56
  ##
43
- # @param [LLM::Agent] agent
57
+ # @param [LLM::Repl] repl
44
58
  # @return [LLM::Repl::Input]
45
- def initialize(agent, options = {})
46
- @agent = agent
47
- @provider = agent.llm.name
59
+ def initialize(repl, options = {})
60
+ @name = repl.name
61
+ @agent = repl.agent
62
+ @provider = @agent.llm.name
48
63
  @buffer = +""
49
64
  @cursor = 0
50
65
  @scroll = 0
51
66
  @height = options.fetch(:height, 3)
52
67
  @last_char_at = nil
68
+ @memory = @agent.messages.select(&:user?).map(&:content)
69
+ @walker = Walker.new(@memory)
53
70
  @paste = false
54
71
  end
55
72
 
@@ -59,8 +76,28 @@ class LLM::Repl
59
76
  # @return [Symbol, nil]
60
77
  def on_char(window, char, now)
61
78
  is_paste = lambda { @last_char_at and (now - @last_char_at) < PASTE_THRESHOLD }
62
- if ESC == char
79
+ if char and @char != char
80
+ REPEATS[@char] = 0
81
+ end
82
+ if PGUP == char
83
+ (window.rows - 3).times { window.scroll_up }
84
+ :pageup
85
+ elsif PGDOWN == char
86
+ (window.rows - 3).times { window.scroll_down }
87
+ :pagedown
88
+ elsif TAB == char
89
+ autocomplete
90
+ :tab
91
+ elsif ESC == char
63
92
  @agent.cancel!
93
+ elsif CTRL[:P] == char
94
+ @buffer = @walker.prev.dup
95
+ @cursor = @buffer.size
96
+ :ctrl_p
97
+ elsif CTRL[:N] == char
98
+ @buffer = @walker.next.dup
99
+ @cursor = @buffer.size
100
+ :ctrl_n
64
101
  elsif CTRL[:D] == char
65
102
  delete
66
103
  :ctrl_d
@@ -85,14 +122,16 @@ class LLM::Repl
85
122
  elsif char == RIGHT
86
123
  move_right
87
124
  :right
88
- elsif BACKSPACE.include?(char)
125
+ elsif BACKSPACE == char
89
126
  backspace
90
127
  :backspace
91
- elsif ENTER.include?(char)
128
+ elsif ENTER == char
92
129
  if @paste = is_paste.()
93
130
  insert("\n")
94
131
  :char
95
132
  else
133
+ @memory.push(@buffer.dup)
134
+ @walker.cursor = @memory.size
96
135
  :submit
97
136
  end
98
137
  elsif char == UP
@@ -108,19 +147,23 @@ class LLM::Repl
108
147
  nil
109
148
  end
110
149
  ensure
111
- @last_char_at = now if char
150
+ if char
151
+ REPEATS[char] += 1
152
+ @last_char_at = now
153
+ @char = char
154
+ end
112
155
  end
113
156
 
114
157
  ##
115
158
  # @return [String]
116
159
  def to_s
117
- "#{@provider}> #{@buffer}"
160
+ "#{prompt}#{@buffer}"
118
161
  end
119
162
 
120
163
  ##
121
164
  # @return [Integer]
122
165
  def cursor
123
- prompt.length + @cursor
166
+ prompt.size + @cursor
124
167
  end
125
168
 
126
169
  ##
@@ -182,6 +225,28 @@ class LLM::Repl
182
225
  @cursor = [0, @cursor + 1].max
183
226
  end
184
227
 
228
+ ##
229
+ # @return [void]
230
+ def autocomplete
231
+ return unless @buffer[0] == "/"
232
+ ##
233
+ # This method implements a simple autocomplete
234
+ # that supports cycling through all known
235
+ # commands. When given tab in quick succession,
236
+ # we cycle to the nearest neighbour for the last
237
+ # full match. However, it's not based on similarity,
238
+ # it's just the next element in the array.
239
+ keys = LLM::Command.registry.keys
240
+ candidates = LLM::Command.complete(@buffer)
241
+ if REPEATS[TAB] >= 1
242
+ candidate = keys[keys.index(candidates[0]) + 1] || keys[0]
243
+ else
244
+ candidate = candidates[0]
245
+ end
246
+ @buffer = "/#{candidate}"
247
+ @cursor = @buffer.size
248
+ end
249
+
185
250
  ##
186
251
  # @return [void]
187
252
  def kill
@@ -238,7 +303,7 @@ class LLM::Repl
238
303
  end
239
304
 
240
305
  def prompt
241
- "#{@provider}> "
306
+ "#{@provider}(#{@name})> "
242
307
  end
243
308
 
244
309
  def insert(char)