btape 0.1.0 → 0.3.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/README.md +297 -9
- data/lib/btape/cli.rb +120 -11
- data/lib/btape/duration.rb +26 -0
- data/lib/btape/error.rb +4 -0
- data/lib/btape/executor.rb +186 -0
- data/lib/btape/generate_command.rb +123 -0
- data/lib/btape/gif_encoder.rb +88 -27
- data/lib/btape/llm/client.rb +141 -0
- data/lib/btape/llm/generator.rb +91 -0
- data/lib/btape/llm/prompt.rb +133 -0
- data/lib/btape/null_logger.rb +14 -0
- data/lib/btape/palette.rb +203 -0
- data/lib/btape/parser.rb +50 -4
- data/lib/btape/recorder.rb +62 -9
- data/lib/btape/result.rb +18 -0
- data/lib/btape/runner.rb +143 -59
- data/lib/btape/settings.rb +151 -0
- data/lib/btape/version.rb +1 -1
- data/lib/btape.rb +10 -0
- metadata +30 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require_relative 'duration'
|
|
5
|
+
require_relative 'null_logger'
|
|
6
|
+
|
|
7
|
+
module Btape
|
|
8
|
+
# Performs the parsed commands against a browser, turning any failure into a
|
|
9
|
+
# ScriptError that points back at the line it came from.
|
|
10
|
+
#
|
|
11
|
+
# It lives apart from Runner because Runner's job is the recording session
|
|
12
|
+
# around the script — the browser, the frames, the GIF — while this is the
|
|
13
|
+
# script itself, and only this grows with each new command.
|
|
14
|
+
class Executor
|
|
15
|
+
# Commands that do something at run time, and the method that does it.
|
|
16
|
+
# Output, Viewport and Set are read before the run starts and have no
|
|
17
|
+
# behaviour of their own here.
|
|
18
|
+
HANDLERS = {
|
|
19
|
+
'Goto' => :goto, 'Click' => :click, 'Type' => :enter, 'Sleep' => :pause,
|
|
20
|
+
'Screenshot' => :screenshot, 'Evaluate' => :evaluate,
|
|
21
|
+
'WaitFor' => :wait_for_element, 'WaitForJS' => :wait_for_js,
|
|
22
|
+
'Frame' => :frame, 'Press' => :press
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
MAIN_FRAME = 'main'
|
|
26
|
+
|
|
27
|
+
def initialize(browser:, recorder:, settings:, lock: Monitor.new, logger: NullLogger.new)
|
|
28
|
+
@browser = browser
|
|
29
|
+
# Elements and JavaScript are looked up in the current frame, which
|
|
30
|
+
# starts as the page itself and moves when a Frame command says so.
|
|
31
|
+
@target = browser
|
|
32
|
+
@recorder = recorder
|
|
33
|
+
@settings = settings
|
|
34
|
+
@lock = lock
|
|
35
|
+
@logger = logger
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(commands)
|
|
39
|
+
commands.each do |command|
|
|
40
|
+
@logger.debug("btape: line #{command.line_number}: #{command.name}")
|
|
41
|
+
perform(command)
|
|
42
|
+
# The run's own deadline arrives asynchronously and usually lands inside
|
|
43
|
+
# a command, which would otherwise be reported as that command failing.
|
|
44
|
+
# A caller has to be able to tell "the run outlasted Set Timeout" from
|
|
45
|
+
# "this line is broken", so it goes out as it came in.
|
|
46
|
+
rescue TimeoutError
|
|
47
|
+
raise
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
raise ScriptError.new(command.line_number, "#{command.name} failed: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Held across each exchange with the browser, and shared with the
|
|
56
|
+
# recorder, so a screenshot taken on the recorder's thread is never in
|
|
57
|
+
# flight at the same time as a command on this one.
|
|
58
|
+
#
|
|
59
|
+
# It is deliberately not held for a whole command: Sleep and the waiting
|
|
60
|
+
# commands spend most of their time not talking to the browser at all,
|
|
61
|
+
# and holding the lock through that would leave interval recording with
|
|
62
|
+
# nothing to capture for the duration.
|
|
63
|
+
def locked(&)
|
|
64
|
+
@lock.synchronize(&)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def perform(command)
|
|
68
|
+
handler = HANDLERS[command.name]
|
|
69
|
+
send(handler, *command.arguments) if handler
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def goto(url)
|
|
73
|
+
locked { @browser.go_to(url) }
|
|
74
|
+
# Whatever frame we were in belongs to the page we just left.
|
|
75
|
+
@target = @browser
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Points the following commands at an iframe, so a tape can reach an API
|
|
79
|
+
# inside it — a slide deck's own navigation, say — rather than only what
|
|
80
|
+
# the outer document exposes. Nesting works by switching again from
|
|
81
|
+
# within, and `Frame main` returns to the page.
|
|
82
|
+
def frame(selector)
|
|
83
|
+
return @target = @browser if selector == MAIN_FRAME
|
|
84
|
+
|
|
85
|
+
# Reading .frame off the node is a further exchange with the browser, so
|
|
86
|
+
# it belongs under the lock as much as the lookup that found the node.
|
|
87
|
+
node = find(selector)
|
|
88
|
+
@target = locked { node.frame } || raise("#{selector} is not a frame")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def press(key, count = '1')
|
|
92
|
+
Integer(count, 10).times { locked { @browser.keyboard.type(key.to_sym) } }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def click(selector)
|
|
96
|
+
locked { find(selector).click }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def enter(selector, text)
|
|
100
|
+
locked do
|
|
101
|
+
element = find(selector)
|
|
102
|
+
element.focus
|
|
103
|
+
element.type(text)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pause(duration)
|
|
108
|
+
sleep(Duration.parse(duration))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def screenshot(name = nil)
|
|
112
|
+
@recorder.capture(name: name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def evaluate(expression)
|
|
116
|
+
locked { @target.evaluate(expression) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def wait_for_element(selector, timeout = nil)
|
|
120
|
+
wait_until(timeout, "#{selector} to appear") { element(selector) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def wait_for_js(expression, timeout = nil)
|
|
124
|
+
wait_until(timeout, "#{expression} to be true") { evaluate(expression) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Polls until the block has been satisfied WaitStable times in a row. The
|
|
128
|
+
# streak matters for pages that report readiness before they have settled:
|
|
129
|
+
# a single true reading can land mid-render, several in a row cannot.
|
|
130
|
+
#
|
|
131
|
+
# A block that raises counts as not-yet-satisfied, since a page part-way
|
|
132
|
+
# through loading will happily throw on a property that is about to
|
|
133
|
+
# exist. The last error is reported if the wait times out, so a broken
|
|
134
|
+
# expression still surfaces rather than being silently polled forever.
|
|
135
|
+
def wait_until(timeout, description)
|
|
136
|
+
timeout = timeout ? Duration.parse(timeout) : @settings.wait_timeout
|
|
137
|
+
deadline = monotonic + timeout
|
|
138
|
+
stable = 0
|
|
139
|
+
failure = nil
|
|
140
|
+
|
|
141
|
+
loop do
|
|
142
|
+
satisfied = begin
|
|
143
|
+
yield
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
failure = e
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
stable = satisfied ? stable + 1 : 0
|
|
150
|
+
return if stable >= @settings.wait_stable
|
|
151
|
+
raise Error, timed_out(description, timeout, failure) if monotonic >= deadline
|
|
152
|
+
|
|
153
|
+
sleep(@settings.wait_interval)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def timed_out(description, timeout, failure)
|
|
158
|
+
message = "timed out after #{timeout}s waiting for #{description}"
|
|
159
|
+
failure ? "#{message} (last error: #{failure.message})" : message
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def monotonic
|
|
163
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def find(selector)
|
|
167
|
+
element(selector) || raise("element not found: #{selector}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def element(selector)
|
|
171
|
+
locked do
|
|
172
|
+
next @target.at_css(selector) unless selector.start_with?('text=')
|
|
173
|
+
|
|
174
|
+
literal = xpath_literal(selector.delete_prefix('text='))
|
|
175
|
+
@target.at_xpath("//*[normalize-space(text())=#{literal}]")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def xpath_literal(text)
|
|
180
|
+
return %("#{text}") unless text.include?('"')
|
|
181
|
+
|
|
182
|
+
parts = text.split('"', -1).map { |part| %("#{part}") }
|
|
183
|
+
"concat(#{parts.join(%q(, '"', ))})"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
require_relative 'llm/client'
|
|
7
|
+
require_relative 'llm/generator'
|
|
8
|
+
require_relative 'null_logger'
|
|
9
|
+
|
|
10
|
+
module Btape
|
|
11
|
+
# `btape generate` — asks a model running on this machine for a tape and
|
|
12
|
+
# writes it out, having first checked that what came back is one.
|
|
13
|
+
#
|
|
14
|
+
# It is a separate command rather than a flag on a recording because it
|
|
15
|
+
# records nothing: no browser is opened, and the answer is a file to read,
|
|
16
|
+
# edit and then run like any other tape.
|
|
17
|
+
class GenerateCommand
|
|
18
|
+
USAGE = 'Usage: btape generate [options] DESCRIPTION'
|
|
19
|
+
|
|
20
|
+
Options = Struct.new(:help, :output_path, :context_path, :client, :verbose, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
def initialize(out: $stdout, err: $stderr, stdin: $stdin, generator: nil)
|
|
23
|
+
@out = out
|
|
24
|
+
@err = err
|
|
25
|
+
@stdin = stdin
|
|
26
|
+
@generator = generator
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(argv)
|
|
30
|
+
options = Options.new(help: false, client: {}, verbose: false)
|
|
31
|
+
words = parse_options(argv, options)
|
|
32
|
+
return print_help if options.help
|
|
33
|
+
|
|
34
|
+
write(generator(options).call(description(words), context: context(options)), options)
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def write(script, options)
|
|
41
|
+
return @out.print(script) unless options.output_path
|
|
42
|
+
|
|
43
|
+
path = File.expand_path(options.output_path)
|
|
44
|
+
File.write(path, script, encoding: Encoding::UTF_8)
|
|
45
|
+
@out.puts "Wrote #{path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The description is the words left after the flags, or standard input
|
|
49
|
+
# when there are none — a paragraph about what to record is easier to
|
|
50
|
+
# write in a file, or to pipe in, than to quote on a command line.
|
|
51
|
+
def description(words)
|
|
52
|
+
return words.join(' ') unless words.empty?
|
|
53
|
+
raise Error, USAGE if @stdin.tty?
|
|
54
|
+
|
|
55
|
+
piped = @stdin.read.to_s
|
|
56
|
+
raise Error, USAGE if piped.strip.empty?
|
|
57
|
+
|
|
58
|
+
piped
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def context(options)
|
|
62
|
+
return nil unless options.context_path
|
|
63
|
+
|
|
64
|
+
File.read(File.expand_path(options.context_path), encoding: Encoding::UTF_8)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def generator(options)
|
|
68
|
+
@generator || LLM::Generator.new(client: LLM::Client.new(**options.client), logger: logger(options))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def logger(options)
|
|
72
|
+
return NullLogger.new unless options.verbose
|
|
73
|
+
|
|
74
|
+
Logger.new(@err, level: Logger::DEBUG, formatter: ->(_severity, _time, _program, message) { "#{message}\n" })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_options(argv, options)
|
|
78
|
+
option_parser(options).parse(argv)
|
|
79
|
+
rescue OptionParser::ParseError => e
|
|
80
|
+
raise Error, e.message
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def option_parser(options)
|
|
84
|
+
OptionParser.new do |parser|
|
|
85
|
+
parser.banner = USAGE
|
|
86
|
+
parser.on('--llm-url URL', 'The OpenAI-compatible model server to ask') do |url|
|
|
87
|
+
options.client[:base_url] = url
|
|
88
|
+
end
|
|
89
|
+
parser.on('--model NAME', 'Ask for this model rather than whichever one is loaded') do |name|
|
|
90
|
+
options.client[:model] = name
|
|
91
|
+
end
|
|
92
|
+
parser.on('--temperature N', Float, 'How freely the model writes; 0.2 by default') do |value|
|
|
93
|
+
options.client[:temperature] = value
|
|
94
|
+
end
|
|
95
|
+
parser.on('--context FILE', 'Give the model this file as context: selectors, notes, markup') do |path|
|
|
96
|
+
options.context_path = path
|
|
97
|
+
end
|
|
98
|
+
parser.on('-o', '--out FILE', 'Write the tape here rather than to standard output') do |path|
|
|
99
|
+
options.output_path = path
|
|
100
|
+
end
|
|
101
|
+
parser.on('--verbose', 'Report each attempt on stderr') { options.verbose = true }
|
|
102
|
+
parser.on('-h', '--help', 'Show this message') { options.help = true }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def print_help
|
|
107
|
+
@out.puts USAGE
|
|
108
|
+
@out.puts
|
|
109
|
+
@out.puts 'Options:'
|
|
110
|
+
@out.puts ' --llm-url URL The OpenAI-compatible model server to ask'
|
|
111
|
+
@out.puts ' --model NAME Ask for this model rather than whichever one is loaded'
|
|
112
|
+
@out.puts ' --temperature N How freely the model writes; 0.2 by default'
|
|
113
|
+
@out.puts ' --context FILE Give the model this file as context: selectors, notes, markup'
|
|
114
|
+
@out.puts ' -o, --out FILE Write the tape here rather than to standard output'
|
|
115
|
+
@out.puts ' --verbose Report each attempt on stderr'
|
|
116
|
+
@out.puts
|
|
117
|
+
@out.puts "Defaults to #{LLM::Client::DEFAULT_BASE_URL}, which is where LM Studio serves."
|
|
118
|
+
@out.puts 'Ollama serves the same API at http://localhost:11434/v1.'
|
|
119
|
+
@out.puts 'BTAPE_LLM_URL, BTAPE_LLM_MODEL and BTAPE_LLM_KEY are used when the flags are not given.'
|
|
120
|
+
0
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/btape/gif_encoder.rb
CHANGED
|
@@ -2,62 +2,123 @@
|
|
|
2
2
|
|
|
3
3
|
require 'chunky_png'
|
|
4
4
|
require_relative 'lzw_compressor'
|
|
5
|
+
require_relative 'palette'
|
|
5
6
|
|
|
6
7
|
module Btape
|
|
7
|
-
# A deliberately small GIF89a encoder
|
|
8
|
-
#
|
|
8
|
+
# A deliberately small GIF89a encoder, so that recording needs no native
|
|
9
|
+
# image or video dependency.
|
|
9
10
|
class GifEncoder
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
# A GIF frame delay is two bytes of hundredths of a second.
|
|
12
|
+
MAX_DELAY = 0xffff
|
|
13
|
+
# Eight bits per pixel: one index into a 256-colour table.
|
|
14
|
+
MIN_CODE_SIZE = "\x08"
|
|
15
|
+
|
|
16
|
+
def self.for(settings)
|
|
17
|
+
new(
|
|
18
|
+
delay: (settings.frame_delay * 100).round,
|
|
19
|
+
loop_count: settings.loop_count,
|
|
20
|
+
quantizer: settings.quantizer,
|
|
21
|
+
scale: settings.scale,
|
|
22
|
+
width: settings.output_width
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(delay: 10, loop_count: 0, quantizer: :adaptive, scale: 1.0, width: nil, dedupe: true)
|
|
27
|
+
@delay = delay.clamp(1, MAX_DELAY)
|
|
28
|
+
@loop_count = loop_count
|
|
29
|
+
@quantizer = quantizer
|
|
30
|
+
@scale = scale
|
|
31
|
+
@width = width
|
|
32
|
+
@dedupe = dedupe
|
|
12
33
|
@compressor = LzwCompressor.new
|
|
13
34
|
end
|
|
14
35
|
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
# Returns the GIF as a binary String. Frames are PNG paths or ChunkyPNG
|
|
37
|
+
# canvases. Callers that are not writing to the filesystem — attaching the
|
|
38
|
+
# GIF to a record, say — want this rather than a file to read back.
|
|
39
|
+
def encode(frames)
|
|
40
|
+
raise Error, 'no screenshots were captured' if frames.empty?
|
|
17
41
|
|
|
18
|
-
images =
|
|
42
|
+
images = frames.map { |frame| resize(image(frame)) }
|
|
19
43
|
width, height = images.first.dimension.to_a
|
|
20
44
|
raise Error, 'captured screenshots have different dimensions' unless images.all? do |image|
|
|
21
45
|
image.dimension.to_a == [width, height]
|
|
22
46
|
end
|
|
23
47
|
|
|
24
|
-
|
|
48
|
+
palette = Palette.build(@quantizer, images)
|
|
49
|
+
gif(collapse(images.map { |image| index(image, palette) }), width, height, palette)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Writes to a path, or to anything that responds to write.
|
|
53
|
+
def write(frames, output)
|
|
54
|
+
data = encode(frames)
|
|
55
|
+
return output.write(data) if output.respond_to?(:write)
|
|
56
|
+
|
|
57
|
+
File.binwrite(output, data)
|
|
25
58
|
end
|
|
26
59
|
|
|
27
60
|
private
|
|
28
61
|
|
|
29
|
-
def
|
|
62
|
+
def image(frame)
|
|
63
|
+
frame.is_a?(ChunkyPNG::Canvas) ? frame : ChunkyPNG::Image.from_file(frame)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resize(image)
|
|
67
|
+
target = @width || (image.width * @scale).round
|
|
68
|
+
return image if target == image.width || target < 1
|
|
69
|
+
|
|
70
|
+
# A wide enough source rounds its scaled height down to nothing, and a
|
|
71
|
+
# zero-height canvas becomes a GIF no decoder can show.
|
|
72
|
+
height = [(image.height * target.to_f / image.width).round, 1].max
|
|
73
|
+
image.resample_bilinear(target, height)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def index(image, palette)
|
|
77
|
+
image.pixels.map { |pixel| palette.index_for(pixel) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A run that sits on one page for a second is a dozen identical frames.
|
|
81
|
+
# Holding the first for longer says the same thing in a fraction of the
|
|
82
|
+
# bytes, and spares a decoder the redundant frames.
|
|
83
|
+
def collapse(frames)
|
|
84
|
+
frames.each_with_object([]) do |pixels, collapsed|
|
|
85
|
+
previous = collapsed.last
|
|
86
|
+
if @dedupe && previous && previous.first == pixels
|
|
87
|
+
previous[1] = [previous[1] + @delay, MAX_DELAY].min
|
|
88
|
+
else
|
|
89
|
+
collapsed << [pixels, @delay]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def gif(frames, width, height, palette)
|
|
30
95
|
data = +'GIF89a'.b
|
|
31
|
-
data << [width, height, 0b11110111, 0, 0].pack('vvCCC') << palette
|
|
32
|
-
data <<
|
|
33
|
-
|
|
96
|
+
data << [width, height, 0b11110111, 0, 0].pack('vvCCC') << palette.to_gct
|
|
97
|
+
data << netscape
|
|
98
|
+
frames.each { |pixels, delay| write_frame(data, pixels, delay, width, height) }
|
|
34
99
|
data << ';'.b
|
|
35
100
|
end
|
|
36
101
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
data << "\x08".b
|
|
42
|
-
compressed.bytes.each_slice(255) { |slice| data << slice.length.chr << slice.pack('C*') }
|
|
43
|
-
data << "\x00".b
|
|
102
|
+
# The application extension that carries the loop count. Zero loops
|
|
103
|
+
# forever, which is what an unattended recording usually wants.
|
|
104
|
+
def netscape
|
|
105
|
+
+"!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop_count].pack('v') << "\x00".b
|
|
44
106
|
end
|
|
45
107
|
|
|
46
|
-
def
|
|
47
|
-
|
|
108
|
+
def write_frame(data, pixels, delay, width, height)
|
|
109
|
+
data << "!\xF9\x04\x00".b << [delay].pack('v') << "\x00\x00".b
|
|
110
|
+
data << ','.b << [0, 0, width, height, 0].pack('vvvvC')
|
|
111
|
+
data << MIN_CODE_SIZE.b
|
|
112
|
+
lzw(pixels).bytes.each_slice(255) { |slice| data << slice.length.chr << slice.pack('C*') }
|
|
113
|
+
data << "\x00".b
|
|
48
114
|
end
|
|
49
115
|
|
|
50
|
-
def lzw(
|
|
51
|
-
pixels = image.pixels.map { |pixel| rgb332(pixel) }
|
|
116
|
+
def lzw(pixels)
|
|
52
117
|
codes = []
|
|
53
118
|
@compressor.call(pixels) { |code, width| codes << [code, width] }
|
|
54
119
|
pack_codes(codes)
|
|
55
120
|
end
|
|
56
121
|
|
|
57
|
-
def rgb332(pixel)
|
|
58
|
-
(ChunkyPNG::Color.r(pixel) & 0xe0) | ((ChunkyPNG::Color.g(pixel) & 0xe0) >> 3) | (ChunkyPNG::Color.b(pixel) >> 6)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
122
|
def pack_codes(codes)
|
|
62
123
|
buffer = 0
|
|
63
124
|
count = 0
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative '../error'
|
|
7
|
+
|
|
8
|
+
module Btape
|
|
9
|
+
# Talking to a language model, which btape does for one purpose: writing a
|
|
10
|
+
# tape from a description of the recording someone wants.
|
|
11
|
+
module LLM
|
|
12
|
+
# Raised when the model, or the server in front of it, could not answer.
|
|
13
|
+
class Error < Btape::Error; end
|
|
14
|
+
|
|
15
|
+
# A chat client for an OpenAI-compatible server. Nothing here knows which
|
|
16
|
+
# one it is talking to: LM Studio, Ollama, llama.cpp's server and vLLM all
|
|
17
|
+
# answer `/v1/chat/completions` in the same shape, so pointing `--llm-url`
|
|
18
|
+
# at one of them is the whole of the configuration.
|
|
19
|
+
#
|
|
20
|
+
# The defaults assume the model is running on this machine, where there is
|
|
21
|
+
# usually no key to send and no reason for the request to leave it.
|
|
22
|
+
class Client
|
|
23
|
+
# LM Studio's; Ollama serves the same API on 11434.
|
|
24
|
+
DEFAULT_BASE_URL = 'http://localhost:1234/v1'
|
|
25
|
+
DEFAULT_TEMPERATURE = 0.2
|
|
26
|
+
# A local model on a CPU answers in tens of seconds rather than the
|
|
27
|
+
# hundreds of milliseconds a hosted one would take.
|
|
28
|
+
DEFAULT_TIMEOUT = 300
|
|
29
|
+
|
|
30
|
+
# How much of a server's error body to quote back. Enough to name the
|
|
31
|
+
# problem, not so much that a stack trace fills the terminal.
|
|
32
|
+
ERROR_BODY_LIMIT = 500
|
|
33
|
+
|
|
34
|
+
attr_reader :base_url
|
|
35
|
+
|
|
36
|
+
def initialize(base_url: nil, model: nil, api_key: nil, temperature: nil, timeout: nil)
|
|
37
|
+
@base_url = (base_url || ENV.fetch('BTAPE_LLM_URL', DEFAULT_BASE_URL)).chomp('/')
|
|
38
|
+
@model = model || ENV.fetch('BTAPE_LLM_MODEL', nil)
|
|
39
|
+
@api_key = api_key || ENV.fetch('BTAPE_LLM_KEY', nil)
|
|
40
|
+
@temperature = temperature || DEFAULT_TEMPERATURE
|
|
41
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Sends the conversation and returns the reply's text.
|
|
45
|
+
def complete(messages)
|
|
46
|
+
payload = { model: model, messages: messages, temperature: @temperature, stream: false }
|
|
47
|
+
body = post('/chat/completions', payload)
|
|
48
|
+
content = body.dig('choices', 0, 'message', 'content')
|
|
49
|
+
# Not every server answers with a String there: some hand back the
|
|
50
|
+
# content as a list of parts, and a reasoning model may answer with
|
|
51
|
+
# nothing but its thoughts. Both are this client's error to report,
|
|
52
|
+
# rather than a NoMethodError from inside it.
|
|
53
|
+
raise Error, "#{@base_url} answered without a message" unless content.is_a?(String) && !content.strip.empty?
|
|
54
|
+
|
|
55
|
+
content
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The model to ask for. A server hosting one model still wants to be
|
|
59
|
+
# told which, and the name differs with every download — so when nobody
|
|
60
|
+
# has said, the loaded one is asked for by name.
|
|
61
|
+
def model
|
|
62
|
+
@model ||= first_loaded_model
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def first_loaded_model
|
|
68
|
+
entry = get('/models')['data']&.first
|
|
69
|
+
name = entry['id'] if entry.is_a?(Hash)
|
|
70
|
+
raise Error, "no model is loaded at #{@base_url}; load one, or name it with --model" unless name
|
|
71
|
+
|
|
72
|
+
name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def post(path, payload)
|
|
76
|
+
request = Net::HTTP::Post.new(url_for(path))
|
|
77
|
+
request['content-type'] = 'application/json'
|
|
78
|
+
request.body = JSON.generate(payload)
|
|
79
|
+
send_request(request)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def get(path)
|
|
83
|
+
send_request(Net::HTTP::Get.new(url_for(path)))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def url_for(path)
|
|
87
|
+
URI.parse("#{@base_url}#{path}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def send_request(request)
|
|
91
|
+
request['authorization'] = "Bearer #{@api_key}" if @api_key
|
|
92
|
+
response = transport(request.uri).request(request)
|
|
93
|
+
parse(response)
|
|
94
|
+
# A local server generating a long answer is the one most likely to be
|
|
95
|
+
# killed part way through it, and the connection dropping is how that
|
|
96
|
+
# arrives here.
|
|
97
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Net::HTTPBadResponse
|
|
98
|
+
raise Error, "#{@base_url} closed the connection before answering; did the model run out of memory?"
|
|
99
|
+
# Everything else the network can say — refused, unreachable, no such
|
|
100
|
+
# host, a route that went away — is the same thing to whoever ran the
|
|
101
|
+
# command, and it is worth naming the address they can go and check.
|
|
102
|
+
rescue SocketError, SystemCallError => e
|
|
103
|
+
raise Error, "could not reach a model server at #{@base_url} (#{e.message}); is it running?"
|
|
104
|
+
# Net::OpenTimeout and Net::ReadTimeout are both Timeout::Errors, so
|
|
105
|
+
# this covers a server that accepted the connection and then thought
|
|
106
|
+
# about it for too long as well as one that never accepted it.
|
|
107
|
+
rescue Timeout::Error
|
|
108
|
+
raise Error, "#{@base_url} did not answer within #{@timeout}s"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def transport(uri)
|
|
112
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
113
|
+
http.use_ssl = uri.scheme == 'https'
|
|
114
|
+
http.open_timeout = @timeout
|
|
115
|
+
http.read_timeout = @timeout
|
|
116
|
+
http
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse(response)
|
|
120
|
+
body = begin
|
|
121
|
+
JSON.parse(response.body.to_s)
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
raise Error, failure_message(response, body) unless response.is_a?(Net::HTTPSuccess)
|
|
126
|
+
raise Error, "#{@base_url} answered with something that is not JSON" if body.nil?
|
|
127
|
+
|
|
128
|
+
body
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# An OpenAI-compatible server reports its own failures as
|
|
132
|
+
# `{"error": {"message": ...}}`, and the ones that do not are quoted as
|
|
133
|
+
# they came so the reason is not lost to the status code alone.
|
|
134
|
+
def failure_message(response, body)
|
|
135
|
+
detail = body.is_a?(Hash) ? (body.dig('error', 'message') || body['error']) : nil
|
|
136
|
+
detail = response.body.to_s.strip[0, ERROR_BODY_LIMIT] if detail.nil? || detail.to_s.empty?
|
|
137
|
+
"#{@base_url} answered #{response.code}#{": #{detail}" unless detail.to_s.empty?}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../error'
|
|
4
|
+
require_relative '../null_logger'
|
|
5
|
+
require_relative '../parser'
|
|
6
|
+
require_relative 'client'
|
|
7
|
+
require_relative 'prompt'
|
|
8
|
+
|
|
9
|
+
module Btape
|
|
10
|
+
module LLM
|
|
11
|
+
# Turns a description of a recording into a tape, by asking a model for
|
|
12
|
+
# one and then holding it to the language: what comes back is parsed
|
|
13
|
+
# before it is handed on, and a reply that does not parse goes back with
|
|
14
|
+
# the parser's complaint attached.
|
|
15
|
+
#
|
|
16
|
+
# That loop is the point of generating a tape rather than pasting one out
|
|
17
|
+
# of a chat window. A small local model reliably invents a command or
|
|
18
|
+
# forgets a quote; it just as reliably fixes it when told which line.
|
|
19
|
+
class Generator
|
|
20
|
+
ATTEMPTS = 3
|
|
21
|
+
|
|
22
|
+
# Models are told not to fence their answer, and fence it anyway.
|
|
23
|
+
FENCED = /```[\w+-]*\n(.*?)```/m
|
|
24
|
+
|
|
25
|
+
def initialize(client: Client.new, attempts: ATTEMPTS, logger: NullLogger.new)
|
|
26
|
+
@client = client
|
|
27
|
+
@attempts = attempts
|
|
28
|
+
@logger = logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the tape as a String. Raises LLM::Error if the model could not
|
|
32
|
+
# be reached, or would not produce a tape that parses.
|
|
33
|
+
def call(description, context: nil)
|
|
34
|
+
raise Error, 'nothing was said about what to record' if description.to_s.strip.empty?
|
|
35
|
+
|
|
36
|
+
messages = [
|
|
37
|
+
{ role: 'system', content: Prompt.system },
|
|
38
|
+
{ role: 'user', content: Prompt.user(description.strip, context: context) }
|
|
39
|
+
]
|
|
40
|
+
attempt(messages)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def attempt(messages)
|
|
46
|
+
reason = nil
|
|
47
|
+
@attempts.times do |index|
|
|
48
|
+
@logger.debug("btape: asking #{@client.model} for a tape (attempt #{index + 1} of #{@attempts})")
|
|
49
|
+
script = extract(@client.complete(messages))
|
|
50
|
+
reason = fault(script)
|
|
51
|
+
return script if reason.nil?
|
|
52
|
+
|
|
53
|
+
@logger.debug("btape: the tape did not parse (#{reason}); asking again")
|
|
54
|
+
messages += [{ role: 'assistant', content: script }, { role: 'user', content: Prompt.repair(reason) }]
|
|
55
|
+
end
|
|
56
|
+
raise Error, "the model did not write a tape that parses, after #{@attempts} attempts: #{reason}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Why this is not a tape, or nil when it is one. The parser answers most
|
|
60
|
+
# of it; Output is checked here because it is the one requirement that
|
|
61
|
+
# belongs to the script as a whole rather than to any line of it, and a
|
|
62
|
+
# tape without it fails at the start of a recording instead.
|
|
63
|
+
#
|
|
64
|
+
# A second Output is worth another round too: the runner takes the first
|
|
65
|
+
# and says nothing about the rest, so a model that wrote two would
|
|
66
|
+
# otherwise be told it had got it right while half of what it wrote was
|
|
67
|
+
# quietly dropped.
|
|
68
|
+
def fault(script)
|
|
69
|
+
commands = Parser.new.parse(script)
|
|
70
|
+
return 'it contained no commands' if commands.empty?
|
|
71
|
+
|
|
72
|
+
outputs = commands.count { |command| command.name == 'Output' }
|
|
73
|
+
return 'it has no Output line, so there is nowhere for the GIF to go' if outputs.zero?
|
|
74
|
+
return "it has #{outputs} Output lines, and a run writes one GIF" if outputs > 1
|
|
75
|
+
|
|
76
|
+
nil
|
|
77
|
+
rescue ScriptError => e
|
|
78
|
+
e.message
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The tape out of the reply. A fenced block is taken as the answer and
|
|
82
|
+
# any prose around it dropped; everything else is passed through whole,
|
|
83
|
+
# so that a stray sentence reaches the parser and comes back as
|
|
84
|
+
# something the model is asked to fix.
|
|
85
|
+
def extract(reply)
|
|
86
|
+
match = FENCED.match(reply)
|
|
87
|
+
"#{(match ? match[1] : reply).strip}\n"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|