llm.rb 12.6.0 → 13.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +571 -13
  3. data/LICENSE +21 -93
  4. data/README.md +183 -167
  5. data/bin/llm.rb +124 -0
  6. data/data/deepinfra.json +3 -0
  7. data/data/xai.json +1 -1
  8. data/lib/llm/a2a.rb +1 -1
  9. data/lib/llm/active_record/acts_as_llm.rb +6 -6
  10. data/lib/llm/agent.rb +136 -27
  11. data/lib/llm/buffer.rb +85 -3
  12. data/lib/llm/compactor/null.rb +19 -0
  13. data/lib/llm/compactor/truncate.rb +80 -0
  14. data/lib/llm/compactor.rb +42 -124
  15. data/lib/llm/context.rb +31 -37
  16. data/lib/llm/contract.rb +4 -25
  17. data/lib/llm/function/array.rb +18 -17
  18. data/lib/llm/function/async/group.rb +54 -0
  19. data/lib/llm/function/async/reactor.rb +48 -0
  20. data/lib/llm/function/async/task.rb +83 -0
  21. data/lib/llm/function/fiber/group.rb +46 -0
  22. data/lib/llm/function/fiber/task.rb +62 -0
  23. data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
  24. data/lib/llm/function/fork/job.rb +2 -2
  25. data/lib/llm/function/fork/task.rb +19 -10
  26. data/lib/llm/function/group.rb +40 -0
  27. data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
  28. data/lib/llm/function/ractor/job.rb +9 -3
  29. data/lib/llm/function/ractor/mailbox.rb +2 -0
  30. data/lib/llm/function/ractor/task.rb +23 -15
  31. data/lib/llm/function/{call_group.rb → sequential/group.rb} +12 -8
  32. data/lib/llm/function/sequential/task.rb +49 -0
  33. data/lib/llm/function/task.rb +25 -48
  34. data/lib/llm/function/thread/group.rb +46 -0
  35. data/lib/llm/function/thread/task.rb +60 -0
  36. data/lib/llm/function.rb +54 -65
  37. data/lib/llm/loop_guard.rb +1 -2
  38. data/lib/llm/mcp.rb +22 -0
  39. data/lib/llm/object.rb +2 -1
  40. data/lib/llm/provider.rb +6 -3
  41. data/lib/llm/providers/anthropic.rb +1 -1
  42. data/lib/llm/providers/bedrock/request_adapter.rb +1 -1
  43. data/lib/llm/providers/google.rb +2 -2
  44. data/lib/llm/providers/mistral.rb +1 -1
  45. data/lib/llm/providers/ollama.rb +1 -1
  46. data/lib/llm/providers/openai/responses.rb +1 -1
  47. data/lib/llm/providers/openai.rb +1 -1
  48. data/lib/llm/repl/{transcript.rb → buffer.rb} +34 -21
  49. data/lib/llm/repl/command.rb +47 -13
  50. data/lib/llm/repl/commands/compact.rb +33 -0
  51. data/lib/llm/repl/commands/help.rb +3 -5
  52. data/lib/llm/repl/input.rb +80 -15
  53. data/lib/llm/repl/markdown/table.rb +80 -0
  54. data/lib/llm/repl/markdown.rb +33 -3
  55. data/lib/llm/repl/node.rb +37 -0
  56. data/lib/llm/repl/status.rb +4 -4
  57. data/lib/llm/repl/stream.rb +12 -5
  58. data/lib/llm/repl/walker.rb +46 -0
  59. data/lib/llm/repl/window.rb +31 -32
  60. data/lib/llm/repl.rb +70 -38
  61. data/lib/llm/response.rb +10 -0
  62. data/lib/llm/schema/leaf.rb +5 -0
  63. data/lib/llm/schema/object.rb +11 -5
  64. data/lib/llm/sequel/plugin.rb +6 -6
  65. data/lib/llm/skill.rb +20 -4
  66. data/lib/llm/stream.rb +24 -17
  67. data/lib/llm/tool.rb +20 -4
  68. data/lib/llm/tools/chdir.rb +0 -2
  69. data/lib/llm/tools/{swap_text.rb → edit-file.rb} +3 -3
  70. data/lib/llm/tools/git.rb +11 -4
  71. data/lib/llm/tools/mkdir.rb +4 -1
  72. data/lib/llm/tools/pwd.rb +0 -2
  73. data/lib/llm/tools/read_file.rb +0 -2
  74. data/lib/llm/tools/rg.rb +11 -4
  75. data/lib/llm/tools/ruby.rb +46 -0
  76. data/lib/llm/tools/shell.rb +11 -4
  77. data/lib/llm/tools/utils.rb +31 -0
  78. data/lib/llm/tracer/pretty_logger.rb +127 -0
  79. data/lib/llm/tracer.rb +1 -0
  80. data/lib/llm/version.rb +1 -1
  81. data/lib/llm.rb +25 -5
  82. data/llm.gemspec +11 -5
  83. data/resources/deepdive.md +45 -1198
  84. metadata +39 -17
  85. data/lib/llm/function/call_task.rb +0 -46
  86. data/lib/llm/function/fiber_group.rb +0 -105
  87. data/lib/llm/function/task_group.rb +0 -97
  88. data/lib/llm/function/thread_group.rb +0 -102
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
@@ -198,7 +204,7 @@ class LLM::Function
198
204
  @params = params
199
205
  end
200
206
  else
201
- @params
207
+ @params || LLM::Schema::Object.new({})
202
208
  end
203
209
  end
204
210
 
@@ -213,70 +219,59 @@ 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
 
223
230
  ##
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}.
231
+ # Returns a function as a {LLM::Function::Task LLM::Function::Task}.
230
232
  #
231
233
  # @example
232
- # # Normal usage (via collection)
233
- # ctx.talk(ctx.functions.wait)
234
+ # # As a group
235
+ # ctx.talk(ctx.pending_functions.wait)
234
236
  #
235
- # # Direct usage (uncommon)
236
- # task = tool.spawn(:thread)
237
+ # # As a task
238
+ # task = tool.task(:thread)
237
239
  # result = task.value
238
240
  #
239
241
  # @param [Symbol] strategy
240
242
  # Controls concurrency strategy:
241
- # - `:call`: Call the function sequentially without spawning
243
+ # - `:sequential`: Call the function sequentially
242
244
  # - `:thread`: Use threads
243
- # - `:task`: Use async tasks (requires async gem)
245
+ # - `:async`: Use async tasks (requires async gem)
244
246
  # - `:fork`: Use a forked child process (requires xchan.rb support)
245
247
  # - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
246
- # - `:fork`: Use a forked child process (requires xchan.rb support)
247
248
  # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
248
249
  #
249
250
  # @return [LLM::Function::Task]
250
251
  # 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
252
+ def task(strategy, options = {})
253
+ case strategy
254
+ when :sequential
255
+ Sequential::Task.new(self, options)
256
+ when :async
256
257
  LLM.require "async" unless defined?(::Async)
257
- Async { call! }
258
+ Async::Task.new(self, options)
258
259
  when :thread
259
- Thread.new { call! }.tap { _1.report_on_exception = false }
260
+ Thread::Task.new(self, options)
260
261
  when :fiber
261
- raise ArgumentError, "Fiber concurrency requires Fiber.scheduler" unless Fiber.scheduler
262
- Fiber.schedule { call! }
262
+ Fiber::Task.new(self, options)
263
263
  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
264
+ LLM.require "xchan", "~> 0.22" unless defined?(::Chan::UNIXSocket)
265
+ Fork::Task.new(self, options.merge(tracer: @tracer))
267
266
  when :ractor
268
267
  raise LLM::RactorError, "Ractor concurrency only supports class-based tools" unless Class === @runner
269
268
  if @runner.respond_to?(:skill?) && @runner.skill?
270
269
  raise LLM::RactorError, "Ractor concurrency does not support skill-backed tools"
271
270
  end
272
- span = @tracer&.on_tool_start(id:, name:, arguments:, model:)
273
- Ractor::Task.new(@runner, id, name, arguments, tracer: @tracer, span:).spawn
271
+ Ractor::Task.new(self, options.merge(runner_class: @runner, id:, name:, arguments:, tracer: @tracer, model:))
274
272
  else
275
- raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :call, :thread, :task, :fiber, :fork, or :ractor"
273
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :sequential, :thread, :fiber, :async, :fork, or :ractor"
276
274
  end
277
- Task.new(task, self)
278
- ensure
279
- @called = true
280
275
  end
281
276
 
282
277
  ##
@@ -285,7 +280,7 @@ class LLM::Function
285
280
  # llm = LLM.openai(key: ENV["KEY"])
286
281
  # ctx = LLM::Context.new(llm, tools: [fn1, fn2])
287
282
  # ctx.talk "I want to run the functions"
288
- # ctx.talk ctx.functions.map(&:cancel)
283
+ # ctx.talk ctx.pending_functions.map(&:cancel)
289
284
  # @return [LLM::Function::Return]
290
285
  def cancel(reason: "function call cancelled")
291
286
  Return.new(id, name, {cancelled: true, reason:})
@@ -387,10 +382,4 @@ class LLM::Function
387
382
  rescue => ex
388
383
  Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
389
384
  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
385
  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"}]
@@ -88,7 +88,7 @@ module LLM
88
88
  {
89
89
  name: fn.name,
90
90
  description: fn.description,
91
- input_schema: fn.params || {type: "object", properties: {}}
91
+ input_schema: fn.params.to_h
92
92
  }.compact
93
93
  end
94
94
 
@@ -74,7 +74,7 @@ class LLM::Bedrock
74
74
  name: function.name,
75
75
  description: function.description,
76
76
  inputSchema: {
77
- json: function.params || default_input_schema
77
+ json: function.params.to_h
78
78
  }
79
79
  }
80
80
  }
@@ -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)
@@ -123,7 +123,7 @@ module LLM
123
123
  # @param [LLM::Function] fn
124
124
  # @return [Hash]
125
125
  def adapt_function(fn)
126
- params = fn.params || {type: "object", properties: {}}
126
+ params = fn.params.to_h
127
127
  {
128
128
  type: "function",
129
129
  function: {name: fn.name, description: fn.description, parameters: params}
@@ -102,7 +102,7 @@ module LLM
102
102
  # @param [LLM::Function] fn
103
103
  # @return [Hash]
104
104
  def adapt_function(fn)
105
- params = fn.params || {type: "object", properties: {}}
105
+ params = fn.params.to_h
106
106
  {
107
107
  type: "function", name: fn.name,
108
108
  function: {name: fn.name, description: fn.description, parameters: params}
@@ -92,7 +92,7 @@ class LLM::OpenAI
92
92
  def adapt_function(fn)
93
93
  {
94
94
  type: "function", name: fn.name, description: fn.description,
95
- parameters: (fn.params || {type: "object", properties: {}}).to_h.merge(additionalProperties: false), strict: false
95
+ parameters: fn.params.to_h.merge(additionalProperties: false), strict: false
96
96
  }.compact
97
97
  end
98
98
 
@@ -156,7 +156,7 @@ module LLM
156
156
  # @param [LLM::Function] fn
157
157
  # @return [Hash]
158
158
  def adapt_function(fn)
159
- params = fn.params || {type: "object", properties: {}}
159
+ params = fn.params.to_h
160
160
  {
161
161
  type: "function", name: fn.name,
162
162
  function: {name: fn.name, description: fn.description, parameters: params}
@@ -14,12 +14,13 @@ class LLM::Repl
14
14
  # It also maintains a cursor that tracks the active row
15
15
  # by its index number. The streaming path reuses a single
16
16
  # row by overwriting its contents repeatedly.
17
- class Transcript
18
- WIDTH = 80
19
-
17
+ class Buffer
20
18
  ##
21
- # @return [LLM::Repl::Transcript]
22
- def initialize
19
+ # @param [LLM::Repl] repl
20
+ # An instance of {LLM::Repl LLM::Repl}.
21
+ # @return [LLM::Repl::Buffer]
22
+ def initialize(repl)
23
+ @repl = repl
23
24
  @rows = [[]]
24
25
  @cursor = nil
25
26
  @snapshot = nil
@@ -27,37 +28,45 @@ class LLM::Repl
27
28
  end
28
29
 
29
30
  ##
30
- # @param [String] chars
31
+ # @param [String, Array] chars
31
32
  # @param [Object] attrs
32
33
  # @param [Symbol] method
33
34
  # @return [void]
34
35
  def write(chars, attrs = nil, method: :append)
35
- chunks = [{text: chars.to_s, attrs:}.compact]
36
+ case chars
37
+ when Array then chunks = chars
38
+ else chunks = [Node.new(chars.to_s, attrs)]
39
+ end
36
40
  self.method(method).call(chunks)
37
41
  end
38
42
 
39
43
  ##
40
- # Appends Markdown to the transcript.
41
- # @param [String] chars
44
+ # @param [String] user
45
+ # @param [String, Array] content
42
46
  # @param [Symbol] method
43
47
  # @return [void]
44
- def markdown(chars, method: :append)
45
- chunks = LLM::Repl::Markdown.new(chars).ast
46
- self.method(method).call(chunks)
48
+ def write_message(user, content, method: :append)
49
+ chunks = [Node.new("#{user}: ", Curses::A_BOLD)]
50
+ case content
51
+ when Array then chunks.concat(content)
52
+ else chunks.push(Node.new(content))
53
+ end
54
+ chunks.push(Node.new("\n"))
55
+ write(chunks, method:)
47
56
  end
48
57
 
49
58
  ##
50
- # Start the transcript.
59
+ # Open the buffer.
51
60
  # @return [void]
52
- def start
61
+ def open
53
62
  @cursor = @rows.size - 1
54
63
  @snapshot = @rows.map(&:dup)
55
64
  end
56
65
 
57
66
  ##
58
- # Finish the transcript.
67
+ # Close the buffer.
59
68
  # @return [void]
60
- def finish
69
+ def close
61
70
  @cursor = nil
62
71
  @snapshot = nil
63
72
  end
@@ -93,9 +102,13 @@ class LLM::Repl
93
102
 
94
103
  private
95
104
 
105
+ ##
106
+ # @return [LLM::Repl]
107
+ attr_reader :repl
108
+
96
109
  ##
97
110
  # Appends a new row
98
- # @param [Array<{text: String, attrs?: Integer}>] chunks
111
+ # @param [Array<Node>] chunks
99
112
  # One or more chunks.
100
113
  # @return [void]
101
114
  def append(chunks)
@@ -104,12 +117,12 @@ class LLM::Repl
104
117
 
105
118
  ##
106
119
  # Replaces the content of the active row
107
- # @param [Array<{text: String, attrs?: Integer}>] chunks
120
+ # @param [Array<Node>] chunks
108
121
  # One or more chunks.
109
122
  # @return [void]
110
123
  def replace(chunks)
111
124
  @rows = @snapshot.map(&:dup)
112
- append(chunks)
125
+ chunks.each { wrap(_1, @rows) }
113
126
  end
114
127
 
115
128
  ##
@@ -123,10 +136,10 @@ class LLM::Repl
123
136
  chunk[:text].to_s.each_char do |char|
124
137
  if char == "\n"
125
138
  rows << []
126
- elsif char == " " and sum(rows.last) >= WIDTH
139
+ elsif char == " " and sum(rows.last) >= repl.width
127
140
  rows << []
128
141
  else
129
- rows.last << {text: char, attrs:}.compact
142
+ rows.last << Node.new(char, attrs)
130
143
  end
131
144
  end
132
145
  end
@@ -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,20 +159,36 @@ 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
  ##
153
- # Write a string to the transcript
154
- # @param [String] str
179
+ # Write a string to the buffer
180
+ # @param [String] content
181
+ # @return [void]
182
+ def write(content)
183
+ write_message "command(#{self.class.name})", content
184
+ end
185
+
186
+ ##
187
+ # @param [String] user
188
+ # @param [String] content
155
189
  # @return [void]
156
- def write(str, who: "command(#{self.class.name}): ")
157
- @repl.write(who, Curses::A_BOLD)
158
- @repl.write(str)
190
+ def write_message(user, content)
191
+ @repl.write_message(user, content)
159
192
  end
160
193
 
161
194
  ##
@@ -191,6 +224,7 @@ class LLM::Repl
191
224
  self.class.parameters
192
225
  end
193
226
 
227
+ require_relative "commands/compact"
194
228
  require_relative "commands/exit"
195
229
  require_relative "commands/help"
196
230
  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"
21
+ compactor.call(keep: n)
22
+ write "compact complete"
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
@@ -11,13 +11,11 @@ class LLM::Repl
11
11
  # @return [void]
12
12
  def call(name: nil)
13
13
  if name.nil?
14
- write("\n#{self.class.help}\n\n")
14
+ write(self.class.help)
15
15
  elsif command = LLM::Command.find_by(name:)
16
- write("\n#{command.help}\n\n")
16
+ write(command.help)
17
17
  else
18
- write "\nNo help for #{name} was found" \
19
- "\nThat command doesn't exist." \
20
- "\n\n"
18
+ write "no help for #{name} was found"
21
19
  end
22
20
  end
23
21
  end