btape 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18261c2d64fe2120e746ab4e2991a85d8c6327aa32ad497125e6b8b1efb3779a
4
- data.tar.gz: aa731da95d316a63404d3ae6a89f03d3c435bea95ff26eeda824c699f8390ef7
3
+ metadata.gz: f7ea547bca5c9c270e16d779e170c68b0ab30b8bc4156cde58d4cfe7560ebbe4
4
+ data.tar.gz: fb55fff428c21bfbc889aa653708a1d02c77ffe795c515238716ad9e246fecf6
5
5
  SHA512:
6
- metadata.gz: dbb660a8f316b473a9c0a62e9842f3cc131e28d5061b49227cdc7b474be90abae39a7dfd5104e5cca26931458e60140a3423c41043de72a2d0e27b2adb51e155
7
- data.tar.gz: 89ddb3f16ff21c0710dce00bfdef3a5588e3ee70a0d7df29373afb32b8e0dbf976ba30f63b83971aedd460ad8ff8c16b980f086f0d58f8d4699fe335554c1144
6
+ metadata.gz: 715e86c7d8e3d18834de6350eacac2ddad337d031faf3fae46f57d86364c445679509d9ef9db25e74688df5ac5c8c341a34418d85fb597b82ec312f2d02fd9d9
7
+ data.tar.gz: 3b67abd203cd1619bc2de88fb4259437a994ec970ef58e81c937ca68efc0ebebc71588942942efa06e6dc56191af8935891c945d5241d87df3c5440787fa18be
data/README.md CHANGED
@@ -9,6 +9,11 @@
9
9
  and captures PNG frames, and a pure-Ruby encoder produces the GIF. It
10
10
  does not require Playwright, Selenium, ffmpeg, or an external service.
11
11
 
12
+ Tapes are written by hand, or asked of a language model running on the same
13
+ machine: `btape generate` describes the language to LM Studio, Ollama or
14
+ anything else speaking their API, and holds the answer to the parser before
15
+ handing it over.
16
+
12
17
  ## Commands
13
18
 
14
19
  ```text
@@ -88,10 +93,79 @@ Usage: btape [options] SCRIPT.tape
88
93
  --set NAME=VALUE Override a setting, as a Set line would
89
94
  --frames-dir DIR Write the PNG frames here and keep them
90
95
  --verbose Report each command on stderr as it runs
96
+
97
+ Subcommands:
98
+ generate DESCRIPTION Write a tape by asking a local model; btape generate --help
91
99
  ```
92
100
 
93
101
  `BTAPE_WS_URL` is used when neither `--ws-url` nor `--set WsUrl=` is given.
94
102
 
103
+ ### Fonts, and text that is not Latin
104
+
105
+ Glyphs come from the fonts the browser can see, which is not necessarily the
106
+ machine btape runs on: the host when btape launches Chromium itself, the other
107
+ machine when `Set WsUrl` points at one, and the image when either of those is
108
+ a container. Nothing raises when a script has no coverage — the page records as
109
+ rows of tofu boxes instead — so a font missing from a headless image shows up
110
+ in the GIF and nowhere earlier.
111
+
112
+ Install fonts covering the scripts the tapes visit. On Debian or Ubuntu:
113
+
114
+ ```sh
115
+ apt-get install fonts-noto-core # most scripts, Latin included
116
+ apt-get install fonts-noto-cjk # Chinese, Japanese, Korean
117
+ apt-get install fonts-ipafont fonts-ipaexfont # Japanese, as IPAGothic, IPAexGothic, IPAMincho
118
+ ```
119
+
120
+ Fontconfig reads `/usr/share/fonts`, `/usr/local/share/fonts`,
121
+ `~/.local/share/fonts` and `~/.fonts`, and font files copied in by hand need
122
+ an `fc-cache -f` after them. The home directory in that list is the one
123
+ belonging to whoever launches the browser, which under a service manager is
124
+ often not the user who installed the font — `sudo -u deploy fc-list` settles
125
+ that faster than another recording does. Chromium reads the configuration as
126
+ it starts, and btape starts one browser per run; a browser shared over `WsUrl`
127
+ keeps the fonts it was launched with until it is restarted.
128
+
129
+ Where one installed font covers a script, that is the whole job: the browser
130
+ falls back to it even for a page that asked for `sans-serif`. Naming a family
131
+ matters when several cover the same script — Noto CJK and IPA together, or a
132
+ developer's macOS with Hiragino already on it. Either force it from the tape:
133
+
134
+ ```text
135
+ Evaluate "document.head.insertAdjacentHTML('beforeend', '<style>*{font-family:IPAexGothic!important}</style>')"
136
+ ```
137
+
138
+ which lasts as long as the document it ran in, so it is repeated after each
139
+ `Goto` and inside a `Frame`; or prefer it for everything the machine renders,
140
+ in `/etc/fonts/local.conf`:
141
+
142
+ ```xml
143
+ <?xml version="1.0"?>
144
+ <!DOCTYPE fontconfig SYSTEM "fonts.dtd">
145
+ <fontconfig>
146
+ <alias><family>sans-serif</family><prefer><family>IPAexGothic</family></prefer></alias>
147
+ <alias><family>serif</family><prefer><family>IPAexMincho</family></prefer></alias>
148
+ <alias><family>monospace</family><prefer><family>IPAGothic</family></prefer></alias>
149
+ </fontconfig>
150
+ ```
151
+
152
+ An alias only answers for the generic families. A page naming `Helvetica,
153
+ Arial` ahead of `sans-serif` keeps whatever those resolve to — Liberation Sans,
154
+ in an image that has it — so such a page is served by forcing the family from
155
+ the tape, or by matching those names in the fontconfig file as well. macOS has
156
+ no fontconfig at all, and there the tape is the only route.
157
+
158
+ A tape can check that the font arrived rather than trust the image it runs in,
159
+ since a family that is not installed measures the same as one that does not
160
+ exist:
161
+
162
+ ```text
163
+ WaitForJS "(() => { const c = document.createElement('canvas').getContext('2d'); const w = (f) => { c.font = '48px ' + f; return c.measureText('AあÄ0').width; }; return w('IPAexGothic') !== w('__missing__'); })()" 3s
164
+ ```
165
+
166
+ Tape files themselves are read as UTF-8 whatever the locale says, so a `Type`
167
+ line or a `text=` selector can be written in any script.
168
+
95
169
  ### A browser running somewhere else
96
170
 
97
171
  btape launches its own Chromium by default. Point it at one that is already
@@ -116,6 +190,77 @@ unwinds. `--frames-dir` keeps them:
116
190
  btape --frames-dir frames examples/thumbnails.tape
117
191
  ```
118
192
 
193
+ ## Writing a tape with a local model
194
+
195
+ `btape generate` describes the language to a model running on your own
196
+ machine and asks it for a tape:
197
+
198
+ ```sh
199
+ btape generate "record signing in at localhost:3000 and landing on the dashboard" -o signin.tape
200
+ btape signin.tape
201
+ ```
202
+
203
+ The default is `http://localhost:1234/v1`, which is where LM Studio serves.
204
+ Anything else speaking the same API answers just as well — Ollama on
205
+ `http://localhost:11434/v1`, llama.cpp's server, vLLM — so nothing about the
206
+ description or the page being recorded leaves the machine unless you point
207
+ `--llm-url` somewhere that it does.
208
+
209
+ ```text
210
+ Usage: btape generate [options] DESCRIPTION
211
+
212
+ --llm-url URL The OpenAI-compatible model server to ask
213
+ --model NAME Ask for this model rather than whichever one is loaded
214
+ --temperature N How freely the model writes; 0.2 by default
215
+ --context FILE Give the model this file as context: selectors, notes, markup
216
+ -o, --out FILE Write the tape here rather than to standard output
217
+ --verbose Report each attempt on stderr
218
+ ```
219
+
220
+ `BTAPE_LLM_URL`, `BTAPE_LLM_MODEL` and `BTAPE_LLM_KEY` stand in for the first
221
+ two flags and for a key, which a local server rarely wants and a proxy in
222
+ front of one usually does. With no model named, the server is asked which one
223
+ it has loaded — the name a download was given is not worth remembering.
224
+
225
+ The description can be piped in rather than quoted, which is easier for
226
+ anything longer than a line:
227
+
228
+ ```sh
229
+ btape generate --context app/views/sessions/new.html.erb < what-to-record.txt
230
+ ```
231
+
232
+ What comes back is parsed before you see it, and a tape that does not parse
233
+ goes back to the model with the parser's own complaint — `line 4: unknown
234
+ command "Navigate"` — for it to fix, up to three times. That loop is why this
235
+ is worth more than pasting the command list into a chat window: a small model
236
+ reliably invents a command or drops a quote, and just as reliably repairs it
237
+ when told which line. What it cannot know is your markup, so a tape it wrote
238
+ still names selectors that have to be checked against the page. Read it before
239
+ you run it, the way you would read anything else generated for you.
240
+
241
+ ### A model that is not on this machine
242
+
243
+ `--llm-url` is the whole of the configuration, so a hosted endpoint speaking
244
+ the same API works as well as a local one. Name the model rather than leaving
245
+ it to be discovered: these servers answer `/v1/models` with a catalogue rather
246
+ than with the one thing they have loaded, and the first entry of it is not
247
+ necessarily something that holds a conversation.
248
+
249
+ ```sh
250
+ BTAPE_LLM_KEY=sk-... btape generate --llm-url https://api.openai.com/v1 --model gpt-4.1 "record signing in"
251
+ ```
252
+
253
+ Anthropic serves an OpenAI-compatible layer on the same host as its own API,
254
+ so `--llm-url https://api.anthropic.com/v1 --model claude-opus-5` records too.
255
+ It is meant for trying models rather than for living on, but nothing btape
256
+ asks of it is among the parts that are missing. A model that refuses
257
+ `--temperature` at anything but its default wants `--temperature 1`.
258
+
259
+ Sending the work somewhere else is the thing to weigh, not the flag. A local
260
+ model keeps the description and the `--context` file on the machine that ran
261
+ the command; a hosted one is handed both, and a context file is usually a page
262
+ of your own markup rather than something you would have published.
263
+
119
264
  ## From Ruby
120
265
 
121
266
  `Runner#run` returns a `Btape::Result`:
@@ -155,9 +300,20 @@ or use the encoder on its own, with PNG paths or ChunkyPNG images:
155
300
  Btape::GifEncoder.new(delay: 150, width: 640).encode(frame_paths) # => String
156
301
  ```
157
302
 
303
+ The generator is a plain object too, so an application that already knows what
304
+ it wants recorded can go from a sentence to a GIF without a file in between:
305
+
306
+ ```ruby
307
+ generator = Btape::LLM::Generator.new(client: Btape::LLM::Client.new(base_url: ENV['BTAPE_LLM_URL']))
308
+ tape = generator.call('record the dashboard loading', context: page_markup)
309
+
310
+ Btape::Runner.new.run(Btape::Parser.new.parse(tape), base_directory: '.', output: buffer)
311
+ ```
312
+
158
313
  ## Container development with dip or wip
159
314
 
160
- The development image contains Ruby and Chromium.
315
+ The development image contains Ruby, Chromium and Latin fonts; tapes that
316
+ record other scripts need fonts for them added to it.
161
317
 
162
318
  ```sh
163
319
  dip provision
data/lib/btape/cli.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'logger'
4
4
  require 'optparse'
5
+ require_relative 'generate_command'
6
+ require_relative 'parser'
5
7
 
6
8
  module Btape
7
9
  # Entry point invoked by the `btape` executable: parses argv, runs the
@@ -11,23 +13,24 @@ module Btape
11
13
 
12
14
  USAGE = 'Usage: btape [options] SCRIPT.tape'
13
15
  HELP_ARGUMENTS = %w[help -h --help].freeze
14
- HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
15
- 'Type SELECTOR TEXT', 'Sleep DURATION', 'Set NAME VALUE', 'Screenshot [NAME]',
16
- 'Evaluate JAVASCRIPT', 'WaitFor SELECTOR [TIMEOUT]',
17
- 'WaitForJS JAVASCRIPT [TIMEOUT]', 'Frame SELECTOR|main',
18
- 'Press KEY [COUNT]'].freeze
16
+ HELP_COMMANDS = Parser::SIGNATURES.map { |name, arguments| "#{name} #{arguments}".strip }.freeze
17
+ GENERATE = 'generate'
19
18
  # The two ways a flag can name a browser: --ws-url and --set WsUrl=.
20
19
  WS_URL_KEYS = [:ws_url, 'WsUrl'].freeze
21
20
 
22
21
  # The runner is built after the flags are read, so that --verbose can
23
22
  # reach it. Pass one to use it as given.
24
- def initialize(out: $stdout, err: $stderr, runner: nil)
23
+ def initialize(out: $stdout, err: $stderr, stdin: $stdin, runner: nil, generator: nil)
25
24
  @out = out
26
25
  @err = err
26
+ @stdin = stdin
27
27
  @runner = runner
28
+ @generator = generator
28
29
  end
29
30
 
30
31
  def run(argv)
32
+ return generate(argv.drop(1)) if argv.first == GENERATE
33
+
31
34
  options = Options.new(false, {}, nil, false)
32
35
  arguments = parse_options(argv, options)
33
36
  return print_help if options.help || arguments.empty? || HELP_ARGUMENTS.include?(arguments.first)
@@ -42,9 +45,13 @@ module Btape
42
45
 
43
46
  private
44
47
 
48
+ def generate(argv)
49
+ GenerateCommand.new(out: @out, err: @err, stdin: @stdin, generator: @generator).run(argv)
50
+ end
51
+
45
52
  def record(argument, options)
46
53
  script = File.expand_path(argument)
47
- commands = Parser.new.parse(File.read(script))
54
+ commands = Parser.new.parse(read_script(script))
48
55
  result = runner(options).run(
49
56
  commands,
50
57
  base_directory: File.dirname(script),
@@ -55,6 +62,15 @@ module Btape
55
62
  report_frames(result)
56
63
  end
57
64
 
65
+ # Tapes are UTF-8, not whatever the locale happens to be. A tape that
66
+ # types text or matches on it is as likely to be written in Japanese or
67
+ # Greek as in ASCII, and read through the default external encoding a
68
+ # machine with LANG unset would reject those bytes while parsing rather
69
+ # than while recording.
70
+ def read_script(path)
71
+ File.read(path, encoding: Encoding::UTF_8)
72
+ end
73
+
58
74
  def runner(options)
59
75
  @runner || Runner.new(logger: logger(options))
60
76
  end
@@ -119,6 +135,9 @@ module Btape
119
135
  @out.puts ' --frames-dir DIR Write the PNG frames here and keep them'
120
136
  @out.puts ' --verbose Report each command on stderr as it runs'
121
137
  @out.puts
138
+ @out.puts 'Subcommands:'
139
+ @out.puts ' generate DESCRIPTION Write a tape by asking a local model; btape generate --help'
140
+ @out.puts
122
141
  @out.puts 'Commands:'
123
142
  HELP_COMMANDS.each { |command| @out.puts " #{command}" }
124
143
  @out.puts
@@ -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
@@ -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
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../parser'
4
+ require_relative '../settings'
5
+
6
+ module Btape
7
+ module LLM
8
+ # What the model is told before it is asked for a tape.
9
+ #
10
+ # The command list and the settings table are built from Parser and
11
+ # Settings rather than written out again here, so a command added to the
12
+ # language is a command the model is told about, and a setting cannot be
13
+ # described to it with a default it no longer has.
14
+ module Prompt
15
+ RULES = [
16
+ 'Answer with the contents of a .tape file and nothing else: no explanation, no code fences.',
17
+ 'The script must contain exactly one Output line, naming a .gif file, and it comes first.',
18
+ 'One command per line. Lines starting with # are comments.',
19
+ 'The line is split like a shell command, so any argument containing a space must be quoted ' \
20
+ 'with double quotes.',
21
+ 'Click, WaitFor and Frame take a CSS selector, or text=Some text to match on visible text.',
22
+ 'Durations are a number followed by ms or s, such as 500ms or 1.5s.',
23
+ 'Prefer WaitFor or WaitForJS over Sleep for anything the page has to finish doing; ' \
24
+ 'Sleep is for holding a finished frame on screen long enough to be seen.',
25
+ 'Use only the commands and settings listed above. Do not invent either, ' \
26
+ 'and do not use a shell, a comment or a blank line to stand in for one.'
27
+ ].freeze
28
+
29
+ EXAMPLE = <<~TAPE
30
+ # Signing in, recorded at half size
31
+ Output signin.gif
32
+ Viewport 1280x720
33
+ Set Scale 0.5
34
+
35
+ Goto http://localhost:3000/signin
36
+ WaitFor "#email"
37
+ Type "#email" "demo@example.com"
38
+ Type "#password" "correct horse"
39
+ Click "text=Sign in"
40
+ WaitFor "text=Welcome back" 5s
41
+ Sleep 2s
42
+ TAPE
43
+
44
+ # What a setting takes, as the coercions in Settings enforce it. A
45
+ # default is shown as a tape would have to write it rather than as
46
+ # Settings holds it, since `Set FrameDelay 0.1` is not something the
47
+ # parser would accept back.
48
+ VALUES = {
49
+ duration: 'a duration',
50
+ url: "a #{Settings::URL_SCHEMES.join(', ')} url",
51
+ count: 'a whole number, 0 or more',
52
+ positive_integer: 'a whole number, 1 or more',
53
+ positive_float: 'a number'
54
+ }.freeze
55
+
56
+ module_function
57
+
58
+ def system
59
+ <<~PROMPT
60
+ You write btape scripts. btape runs a .tape file against Chromium and records
61
+ the run as an animated GIF, so a tape is a short, deliberate demonstration
62
+ rather than a test: it moves at a pace somebody can watch.
63
+
64
+ The commands, one per line:
65
+
66
+ #{indent(commands)}
67
+
68
+ A run is configured with `Set NAME VALUE`:
69
+
70
+ #{indent(settings)}
71
+
72
+ Rules:
73
+
74
+ #{indent(RULES.map { |rule| "- #{rule}" }.join("\n"))}
75
+
76
+ An example of a whole tape:
77
+
78
+ #{indent(EXAMPLE)}
79
+ PROMPT
80
+ end
81
+
82
+ def user(description, context: nil)
83
+ return description if context.nil? || context.strip.empty?
84
+
85
+ <<~PROMPT
86
+ #{description}
87
+
88
+ Context about the page being recorded — prefer the selectors it names over
89
+ any you would otherwise guess at:
90
+
91
+ #{context}
92
+ PROMPT
93
+ end
94
+
95
+ # A tape that did not parse goes back with the parser's own complaint,
96
+ # which names the line and what was wrong with it. Saying so beats
97
+ # asking again and hoping, since the model can see what it wrote.
98
+ def repair(reason)
99
+ <<~PROMPT
100
+ btape rejected that tape: #{reason}
101
+
102
+ Answer with the whole corrected tape, and nothing else.
103
+ PROMPT
104
+ end
105
+
106
+ def commands
107
+ Parser::SIGNATURES.map { |name, arguments| "#{name} #{arguments}".strip }.join("\n")
108
+ end
109
+
110
+ def settings
111
+ Settings::DEFINITIONS.map do |name, (_attribute, coercion, default)|
112
+ takes = coercion.is_a?(Array) ? coercion.join('|') : VALUES.fetch(coercion)
113
+ "Set #{name} <#{takes}>#{" — #{literal(coercion, default)} by default" unless default.nil?}"
114
+ end.join("\n")
115
+ end
116
+
117
+ # A coerced default as a tape would write it: durations are held in
118
+ # seconds and go back to the ms or s they were read from.
119
+ def literal(coercion, default)
120
+ return default.to_s unless coercion == :duration
121
+ return "#{(default * 1000).round}ms" if default < 1
122
+
123
+ "#{default.to_i == default ? default.to_i : default}s"
124
+ end
125
+
126
+ # Blank lines are left alone, so that indenting a block into the prompt
127
+ # does not leave trailing whitespace through the middle of it.
128
+ def indent(text)
129
+ text.strip.gsub(/^(?=.)/, ' ')
130
+ end
131
+ end
132
+ end
133
+ end
data/lib/btape/parser.rb CHANGED
@@ -19,6 +19,17 @@ module Btape
19
19
  'Frame' => 1, 'Press' => 1..2
20
20
  }.freeze
21
21
 
22
+ # The same commands as they read to somebody being told about them, which
23
+ # is what `btape help` prints and what a model is handed before it is
24
+ # asked for a tape. ARITY is what a script is held to; a spec keeps the
25
+ # two lists from drifting apart.
26
+ SIGNATURES = {
27
+ 'Output' => 'PATH', 'Viewport' => 'WIDTHxHEIGHT', 'Goto' => 'URL', 'Click' => 'SELECTOR',
28
+ 'Type' => 'SELECTOR TEXT', 'Press' => 'KEY [COUNT]', 'Frame' => 'SELECTOR|main',
29
+ 'Evaluate' => 'JAVASCRIPT', 'WaitFor' => 'SELECTOR [TIMEOUT]', 'WaitForJS' => 'JAVASCRIPT [TIMEOUT]',
30
+ 'Screenshot' => '[NAME]', 'Sleep' => 'DURATION', 'Set' => 'NAME VALUE'
31
+ }.freeze
32
+
22
33
  def parse(source)
23
34
  source.each_line.with_index(1).filter_map do |line, number|
24
35
  text = line.strip
data/lib/btape/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Btape
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/btape.rb CHANGED
@@ -12,4 +12,8 @@ require_relative 'btape/gif_encoder'
12
12
  require_relative 'btape/recorder'
13
13
  require_relative 'btape/executor'
14
14
  require_relative 'btape/runner'
15
+ require_relative 'btape/llm/client'
16
+ require_relative 'btape/llm/prompt'
17
+ require_relative 'btape/llm/generator'
18
+ require_relative 'btape/generate_command'
15
19
  require_relative 'btape/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: btape
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - btape contributors
@@ -65,7 +65,11 @@ files:
65
65
  - lib/btape/duration.rb
66
66
  - lib/btape/error.rb
67
67
  - lib/btape/executor.rb
68
+ - lib/btape/generate_command.rb
68
69
  - lib/btape/gif_encoder.rb
70
+ - lib/btape/llm/client.rb
71
+ - lib/btape/llm/generator.rb
72
+ - lib/btape/llm/prompt.rb
69
73
  - lib/btape/lzw_compressor.rb
70
74
  - lib/btape/null_logger.rb
71
75
  - lib/btape/palette.rb