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 +4 -4
- data/README.md +157 -1
- data/lib/btape/cli.rb +26 -7
- data/lib/btape/generate_command.rb +123 -0
- 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/parser.rb +11 -0
- data/lib/btape/version.rb +1 -1
- data/lib/btape.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7ea547bca5c9c270e16d779e170c68b0ab30b8bc4156cde58d4cfe7560ebbe4
|
|
4
|
+
data.tar.gz: fb55fff428c21bfbc889aa653708a1d02c77ffe795c515238716ad9e246fecf6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 =
|
|
15
|
-
|
|
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(
|
|
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
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.
|
|
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
|