squishling 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/CHANGELOG.md +51 -0
- data/LICENSE +201 -0
- data/README.md +136 -0
- data/Rakefile +12 -0
- data/SECURITY.md +20 -0
- data/docs/configuration.md +128 -0
- data/docs/failures.md +57 -0
- data/docs/routing.md +210 -0
- data/docs/schemas.md +87 -0
- data/lib/squishling/appendices.rb +59 -0
- data/lib/squishling/class_methods.rb +168 -0
- data/lib/squishling/configuration.rb +36 -0
- data/lib/squishling/definition.rb +156 -0
- data/lib/squishling/errors.rb +26 -0
- data/lib/squishling/invoker.rb +151 -0
- data/lib/squishling/params.rb +66 -0
- data/lib/squishling/result.rb +122 -0
- data/lib/squishling/router.rb +90 -0
- data/lib/squishling/schema.rb +80 -0
- data/lib/squishling/source.rb +194 -0
- data/lib/squishling/version.rb +5 -0
- data/lib/squishling/wrapper.rb +34 -0
- data/lib/squishling.rb +65 -0
- metadata +119 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Ruby source for append_instructions items: a class or module body, or a single method, rendered as a
|
|
5
|
+
# fenced code block. Parsed with Prism (a default gem since Ruby 3.3), loaded the first time it's needed.
|
|
6
|
+
module Source
|
|
7
|
+
# owner => { method name, or nil for the module itself => rendered source }. Weak keys, so classes replaced
|
|
8
|
+
# by code reloading (e.g. Rails in development) can be collected.
|
|
9
|
+
CACHE = ObjectSpace::WeakKeyMap.new
|
|
10
|
+
CACHE_LOCK = Mutex.new
|
|
11
|
+
CLASS_NODES = %i[class_node module_node].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def render(item)
|
|
15
|
+
# Methods are keyed by owner and name: `method(:call)` builds a new object on every call.
|
|
16
|
+
owner, name = item.is_a?(Module) ? [item, nil] : [item.owner, item.name]
|
|
17
|
+
CACHE_LOCK.synchronize do
|
|
18
|
+
rendered = (CACHE[owner] ||= {})
|
|
19
|
+
rendered.fetch(name) { rendered[name] = fence(*extract(item)) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def extract(item)
|
|
26
|
+
require "prism"
|
|
27
|
+
item.is_a?(Module) ? module_source(item) : method_source(unwrap(item))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def module_source(mod)
|
|
31
|
+
constant = constant_location(mod)
|
|
32
|
+
sources = constant ? class_bodies(mod, constant) : method_defs(mod)
|
|
33
|
+
raise ConfigurationError, "append_instructions: source for #{mod.inspect} isn't available" if sources.empty?
|
|
34
|
+
|
|
35
|
+
[display_name(mod), sources.join("\n\n")]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every `class X`/`module X` body for the constant that holds one of its methods, so a class reopened across
|
|
39
|
+
# files is covered, or the Class.new/Module.new block the constant is assigned from (`X = Class.new do`).
|
|
40
|
+
def class_bodies(mod, constant)
|
|
41
|
+
anchors = [constant, *own_methods(mod).map { |method| location(method) }].compact
|
|
42
|
+
anchors.group_by(&:first).flat_map do |file, pairs|
|
|
43
|
+
tree, text = parse(file)
|
|
44
|
+
nodes = pairs.map(&:last).uniq.filter_map do |line|
|
|
45
|
+
innermost(tree, line) { |node, enclosing| defines?(node, enclosing, mod, [file, line] == constant) }
|
|
46
|
+
end
|
|
47
|
+
nodes.uniq.sort_by { |node| node.location.start_line }.map { |node| slice(node, text) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Without a constant there's no class body to find reliably (its methods may come from a factory or a
|
|
52
|
+
# class_eval inside unrelated code), so each of its own `def`s is sent instead, and nothing around them.
|
|
53
|
+
def method_defs(mod)
|
|
54
|
+
parsed = {}
|
|
55
|
+
own_methods(mod).sort_by { |method| location(method) || [] }.filter_map { |method| def_source(method, parsed) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def method_source(method)
|
|
59
|
+
label = method_label(method)
|
|
60
|
+
source = def_source(method)
|
|
61
|
+
raise ConfigurationError, "append_instructions: source for #{label} isn't available" unless source
|
|
62
|
+
|
|
63
|
+
[label, source]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The method's own `def`, matched by name and line, or nil (define_method, attr_*, eval'd code).
|
|
67
|
+
def def_source(method, parsed = {})
|
|
68
|
+
file, line = location(method)
|
|
69
|
+
return unless file
|
|
70
|
+
|
|
71
|
+
tree, text = (parsed[file] ||= parse(file))
|
|
72
|
+
node = innermost(tree, line) do |candidate|
|
|
73
|
+
candidate.type == :def_node && candidate.name == method.original_name && candidate.location.start_line == line
|
|
74
|
+
end
|
|
75
|
+
node && slice(node, text)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# [file, line] for code in a readable file. Some Ruby builds add columns to source_location.
|
|
79
|
+
def location(method)
|
|
80
|
+
file, line = method.source_location
|
|
81
|
+
[file, line] if file && File.file?(file)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A squished method resolves to Squishling's wrapper; show the implementation beneath it.
|
|
85
|
+
def unwrap(method)
|
|
86
|
+
Wrapper.implementation(method) or
|
|
87
|
+
raise ConfigurationError, "append_instructions: #{method.name} has no implementation to show"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Named after the def that's shown, so an alias is labeled by its original name.
|
|
91
|
+
def method_label(method)
|
|
92
|
+
owner = method.owner
|
|
93
|
+
return "#{display_name(owner.attached_object)}.#{method.original_name}" if owner.singleton_class?
|
|
94
|
+
|
|
95
|
+
"#{display_name(owner)}##{method.original_name}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The module's own instance and singleton methods, as UnboundMethods.
|
|
99
|
+
def own_methods(mod)
|
|
100
|
+
names = mod.instance_methods(false) + mod.private_instance_methods(false)
|
|
101
|
+
singleton = mod.singleton_class
|
|
102
|
+
singleton_names = mod.singleton_methods(false) + singleton.private_instance_methods(false)
|
|
103
|
+
names.filter_map { |name| own_method(mod, name) } +
|
|
104
|
+
singleton_names.filter_map { |name| own_method(singleton, name) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# instance_method resolves to a prepended module (Squishling's wrapper, or any other) first. Going through
|
|
108
|
+
# instance_method also bypasses a class's own `self.method` (e.g. an HTTP request model).
|
|
109
|
+
def own_method(mod, name)
|
|
110
|
+
method = mod.instance_method(name)
|
|
111
|
+
method = method.super_method until method.nil? || method.owner == mod
|
|
112
|
+
method
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A `class X`/`module X` node whose full lexical name is the module's (so a monkeypatch from inside an
|
|
116
|
+
# unrelated `Vendor::Client` never sends that class for `Billing::Client`), or the Class.new/Module.new block
|
|
117
|
+
# at the constant's assignment, never an unrelated block that happens to define one of its methods.
|
|
118
|
+
def defines?(node, enclosing, mod, at_constant)
|
|
119
|
+
if CLASS_NODES.include?(node.type)
|
|
120
|
+
lexical_name([*enclosing, node]) == module_name(mod)
|
|
121
|
+
else
|
|
122
|
+
at_constant && node.type == :call_node && node.name == :new && node.block &&
|
|
123
|
+
%w[Class Module].include?(node.receiver&.slice)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Where the module's constant is assigned, or nil when it has none in a readable file: anonymous, nested in
|
|
128
|
+
# an anonymous namespace ("#<Module:0x...>::Parser"), given a temporary name (set_temporary_name), or eval'd.
|
|
129
|
+
def constant_location(mod)
|
|
130
|
+
name = module_name(mod)
|
|
131
|
+
file, line = name && Object.const_source_location(name)
|
|
132
|
+
[file, line] if file && File.file?(file)
|
|
133
|
+
rescue NameError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The deepest node containing the line that satisfies the block, which also gets the enclosing nodes.
|
|
138
|
+
def innermost(node, line, enclosing = [], &)
|
|
139
|
+
location = node.location
|
|
140
|
+
return nil unless line.between?(location.start_line, location.end_line)
|
|
141
|
+
|
|
142
|
+
node.compact_child_nodes.each do |child|
|
|
143
|
+
found = innermost(child, line, [*enclosing, node], &)
|
|
144
|
+
return found if found
|
|
145
|
+
end
|
|
146
|
+
yield(node, enclosing) ? node : nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The constant a class/module node defines, from it and the class/module nodes around it
|
|
150
|
+
# (`module Billing; class Client` and `class Billing::Client` are both "Billing::Client").
|
|
151
|
+
def lexical_name(nodes)
|
|
152
|
+
nodes.select { |node| CLASS_NODES.include?(node.type) }.reduce("") do |name, node|
|
|
153
|
+
path = node.constant_path.slice
|
|
154
|
+
next path.delete_prefix("::") if path.start_with?("::") || name.empty?
|
|
155
|
+
|
|
156
|
+
"#{name}::#{path}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Ruby reads source as UTF-8 whatever the locale (e.g. LANG=C in a container).
|
|
161
|
+
def parse(file)
|
|
162
|
+
text = File.read(file, encoding: Encoding::UTF_8).scrub
|
|
163
|
+
[Prism.parse(text).value, text.lines]
|
|
164
|
+
rescue SystemCallError => e
|
|
165
|
+
raise ConfigurationError, "append_instructions: can't read #{file} (#{e.class})"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Give the node's first line its source line's indentation (it may start mid-line, as in
|
|
169
|
+
# `parser = Class.new do`), then strip the indentation shared by every line.
|
|
170
|
+
def slice(node, text)
|
|
171
|
+
lines = "#{text[node.location.start_line - 1][/\A */]}#{node.slice}".lines
|
|
172
|
+
indent = lines.reject { |line| line.strip.empty? }.map { |line| line[/\A */].size }.min || 0
|
|
173
|
+
lines.map { |line| line.strip.empty? ? "\n" : line[indent..] }.join.chomp
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def display_name(mod)
|
|
177
|
+
return "(#{mod.class} instance)" unless mod.is_a?(Module)
|
|
178
|
+
|
|
179
|
+
module_name(mod) || "(anonymous #{mod.is_a?(Class) ? 'class' : 'module'})"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The constant name Ruby assigned, ignoring any `def self.name` override on the class.
|
|
183
|
+
def module_name(mod)
|
|
184
|
+
Module.instance_method(:name).bind_call(mod)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A fence longer than any backtick run in the source, so the code can't close it early.
|
|
188
|
+
def fence(label, code)
|
|
189
|
+
ticks = "`" * [3, (code.scan(/`+/).map(&:size).max || 0) + 1].max
|
|
190
|
+
"#{ticks}ruby\n# Source: #{label}\n#{code}\n#{ticks}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Module prepended to a squishling class. It intercepts squished methods regardless of
|
|
5
|
+
# whether they're defined before or after the `squish` declaration (or at all).
|
|
6
|
+
class Wrapper < Module
|
|
7
|
+
# The method beneath any Squishling wrappers (instance_method resolves to the prepended wrapper first),
|
|
8
|
+
# or nil when the method has no implementation.
|
|
9
|
+
def self.implementation(method)
|
|
10
|
+
method = method.super_method while method&.owner.is_a?(Wrapper)
|
|
11
|
+
method
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def wrap(name)
|
|
15
|
+
return if method_defined?(name, false)
|
|
16
|
+
|
|
17
|
+
wrapper = self
|
|
18
|
+
define_method(name) do |*args, **kwargs, &block|
|
|
19
|
+
impl =
|
|
20
|
+
if defined?(super)
|
|
21
|
+
-> { super(*args, **kwargs, &block) }
|
|
22
|
+
else
|
|
23
|
+
-> { raise NotImplementedError, "#{self.class}##{name} has no implementation" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Router.dispatch(self, name, args, kwargs, impl, wrapper:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def inspect
|
|
31
|
+
"#<Squishling::Wrapper>"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/squishling.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
require "ruby_llm"
|
|
6
|
+
require "schematist"
|
|
7
|
+
|
|
8
|
+
require_relative "squishling/version"
|
|
9
|
+
require_relative "squishling/errors"
|
|
10
|
+
require_relative "squishling/configuration"
|
|
11
|
+
require_relative "squishling/params"
|
|
12
|
+
require_relative "squishling/result"
|
|
13
|
+
require_relative "squishling/schema"
|
|
14
|
+
require_relative "squishling/source"
|
|
15
|
+
require_relative "squishling/appendices"
|
|
16
|
+
require_relative "squishling/definition"
|
|
17
|
+
require_relative "squishling/invoker"
|
|
18
|
+
require_relative "squishling/router"
|
|
19
|
+
require_relative "squishling/wrapper"
|
|
20
|
+
require_relative "squishling/class_methods"
|
|
21
|
+
|
|
22
|
+
# Include Squishling in a class to make it elastic: its squished methods either run their
|
|
23
|
+
# Ruby implementation or send their inputs through an LLM and return schema-validated results.
|
|
24
|
+
module Squishling
|
|
25
|
+
class << self
|
|
26
|
+
def config
|
|
27
|
+
@config ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure
|
|
31
|
+
yield config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset_config!
|
|
35
|
+
@config = Configuration.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def included(base)
|
|
39
|
+
raise ConfigurationError, "Squishling can only be included in a class" unless base.is_a?(Class)
|
|
40
|
+
|
|
41
|
+
base.extend(ClassMethods)
|
|
42
|
+
base.send(:squishling_install_wrapper)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build the typed result for the squished method currently executing, validating it against
|
|
47
|
+
# the output schema. Use this from the deterministic path so both paths return the same type.
|
|
48
|
+
def squishling_result(attrs = nil, **kwargs)
|
|
49
|
+
frame = Router.current_frame(self)
|
|
50
|
+
raise Error, "#{self.class}#squishling_result called outside a squished method" unless frame
|
|
51
|
+
|
|
52
|
+
frame.definition.build_result(attrs || kwargs, squished: false)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
alias_method :result, :squishling_result
|
|
56
|
+
|
|
57
|
+
# Hand the squished method currently executing to the LLM, e.g. from a `rescue` when the Ruby path can't
|
|
58
|
+
# handle this input. Returns the typed result (squished? true, or false when the declared fallback supplied
|
|
59
|
+
# it); return it from the method. The overrides apply to this call only: append_instructions adds to (or,
|
|
60
|
+
# with false, replaces) the declared sections, context is sent alongside the declared squish_context,
|
|
61
|
+
# model/provider/instructions replace the declared ones, and params merge key by key over them.
|
|
62
|
+
def squish!(append_instructions: nil, context: nil, instructions: nil, model: nil, provider: nil, params: nil)
|
|
63
|
+
Router.escalate(self, { append_instructions:, context:, instructions:, model:, provider:, params: })
|
|
64
|
+
end
|
|
65
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: squishling
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michael Carroll
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json_schemer
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ruby_llm
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: schematist
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.1'
|
|
54
|
+
description: 'Squishling makes Ruby classes elastic. Add a squishling to a class and
|
|
55
|
+
it can send inputs straight to an LLM (OpenAI, Anthropic Claude, Google Gemini,
|
|
56
|
+
or any provider RubyLLM supports) and return strict-schema-validated, typed results:
|
|
57
|
+
the same type your Ruby code returns. Use it to gracefully maintain fickle integrations,
|
|
58
|
+
build interfaces for unknown data formats, and rescue production errors by handing
|
|
59
|
+
the failing call to the LLM with the source class as context. Write Ruby only for
|
|
60
|
+
the paths where token costs justify maintaining it. Per-input routing, validated
|
|
61
|
+
retries, and fallbacks for your LLM calls included.'
|
|
62
|
+
email:
|
|
63
|
+
- mc@coolhandlabs.com
|
|
64
|
+
executables: []
|
|
65
|
+
extensions: []
|
|
66
|
+
extra_rdoc_files: []
|
|
67
|
+
files:
|
|
68
|
+
- CHANGELOG.md
|
|
69
|
+
- LICENSE
|
|
70
|
+
- README.md
|
|
71
|
+
- Rakefile
|
|
72
|
+
- SECURITY.md
|
|
73
|
+
- docs/configuration.md
|
|
74
|
+
- docs/failures.md
|
|
75
|
+
- docs/routing.md
|
|
76
|
+
- docs/schemas.md
|
|
77
|
+
- lib/squishling.rb
|
|
78
|
+
- lib/squishling/appendices.rb
|
|
79
|
+
- lib/squishling/class_methods.rb
|
|
80
|
+
- lib/squishling/configuration.rb
|
|
81
|
+
- lib/squishling/definition.rb
|
|
82
|
+
- lib/squishling/errors.rb
|
|
83
|
+
- lib/squishling/invoker.rb
|
|
84
|
+
- lib/squishling/params.rb
|
|
85
|
+
- lib/squishling/result.rb
|
|
86
|
+
- lib/squishling/router.rb
|
|
87
|
+
- lib/squishling/schema.rb
|
|
88
|
+
- lib/squishling/source.rb
|
|
89
|
+
- lib/squishling/version.rb
|
|
90
|
+
- lib/squishling/wrapper.rb
|
|
91
|
+
homepage: https://github.com/Coolhand-Labs/squishling
|
|
92
|
+
licenses:
|
|
93
|
+
- Apache-2.0
|
|
94
|
+
metadata:
|
|
95
|
+
allowed_push_host: https://rubygems.org
|
|
96
|
+
homepage_uri: https://github.com/Coolhand-Labs/squishling
|
|
97
|
+
source_code_uri: https://github.com/Coolhand-Labs/squishling
|
|
98
|
+
changelog_uri: https://github.com/Coolhand-Labs/squishling/blob/main/CHANGELOG.md
|
|
99
|
+
documentation_uri: https://github.com/Coolhand-Labs/squishling/tree/main/docs
|
|
100
|
+
rubygems_mfa_required: 'true'
|
|
101
|
+
rdoc_options: []
|
|
102
|
+
require_paths:
|
|
103
|
+
- lib
|
|
104
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '3.3'
|
|
109
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - ">="
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '0'
|
|
114
|
+
requirements: []
|
|
115
|
+
rubygems_version: 3.6.9
|
|
116
|
+
specification_version: 4
|
|
117
|
+
summary: 'Elastic Ruby classes with LLM batteries included: zero-code integrations,
|
|
118
|
+
graceful error rescue, and code only where it''s worth maintaining.'
|
|
119
|
+
test_files: []
|