Dev Spy

@rebindv0.2.0

See the pointer, window, and color under it while you write a script, record observations and macros, and try AI calls.

Files

Version 0.2.0 (current)

session.luau

-- One recording path for observations and macros, inside Dev Spy's data
-- folder. Every event is one JSON line in sessions/<id>/events.jsonl, in the
-- shape of the relay's own observer (seq, t, kind), written with
-- rb.file.append in small batches. An observation also writes screenshots to
-- frames/ and a sharp crop around each click to crops/, as PNG files the
-- moment they arrive, at moments of intent rather than on a clock. Only
-- counters stay in memory.

local M = {}

-- Dev Spy's data folder, outside the scripts folder
local DATA = "@data/"

local FLUSH_BYTES = 32 * 1024
-- ms between screenshots and ms of motion summed into one line, as the
-- relay's observer
local FRAME_GAP = 250
local MOVE_GAP = 10
-- a burst of input has ended once it has been quiet this long, and a window
-- change has drawn after WINDOW_SETTLE; movement smaller than MOVE_MIN
-- (summed |dx| + |dy|) is not a burst
local SETTLE = 400
local WINDOW_SETTLE = 250
local MOVE_MIN = 16
-- a window whose title keeps changing is shot at most this often, and
-- movement is summed only while it does not pause for MOVE_QUIET
local WINDOW_MIN = 1000
local MOVE_QUIET = 1000
-- clicks whose colour is read again when their burst settles
local CLICKS_MAX = 8
-- a crop around a click, in screen units
local CROP = 160
-- labels for marks come from the clipboard
local LABEL_MAX = 200
-- the relay caps its own observations at 512 MB
local MAX_BYTES = 512 * 1024 * 1024
-- the largest log turned into a playable .luau: every action costs heap
-- while the script is built
local MACRO_SCRIPT_MAX = 4 * 1024 * 1024

local cfg
local notify

-- the session being recorded, and the stopped ones waiting for Save or
-- Discard, newest last
M.live = nil
M.unsaved = {}

local function now()
  return rb.system.time()
end

function M.init(config, toast)
  cfg = config
  notify = toast
end

local function flush(s)
  if s.buf_bytes == 0 then
    return true
  end
  local text = table.concat(s.buf)
  local ok, err = pcall(rb.file.append, s.dir .. "/events.jsonl", text)
  if not ok then
    -- the batch stays, and the recording is stopped on the next tick
    rb.log.warn(
      "Dev Spy: the recording's log was not written: " .. tostring(err)
    )
    s.broken = true
    return false
  end
  table.clear(s.buf)
  s.buf_bytes = 0
  s.log_bytes += #text
  return true
end

local function line(s, fields)
  s.seq += 1
  fields.seq = s.seq
  fields.t = now() - s.started
  -- stringify indents; a JSON string never holds a raw newline, so every
  -- newline and the indent after it are layout
  local text = string.gsub(rb.json.stringify(fields), "\n%s*", "") .. "\n"
  table.insert(s.buf, text)
  s.buf_bytes += #text
  s.events += 1
  if s.buf_bytes >= FLUSH_BYTES then
    flush(s)
  end
end

local function cursor()
  local x, y = rb.system.mouse()
  return { x = x, y = y }
end

local function displays()
  local ok, list = pcall(rb.screen.list)
  return ok and list or {}
end

local function motion(s)
  local m = s.motion
  if m.n == 0 then
    return
  end
  line(s, {
    kind = "move",
    dx = m.dx,
    dy = m.dy,
    n = m.n,
    cursor = { x = m.x, y = m.y },
  })
  m.dx, m.dy, m.n, m.at = 0, 0, 0, now()
end

local function window_line(s, w)
  line(s, {
    kind = "window",
    title = w.title,
    process = w.process,
    x = w.x,
    y = w.y,
    width = w.width,
    height = w.height,
  })
end

local function wanted(s, what)
  return s ~= nil and (s.mode == "observe" or cfg["macro_" .. what])
end

local function failed(s, file, err, cause)
  line(s, {
    kind = "missing",
    file = file,
    cause = cause,
    reason = tostring(err),
  })
  if not s.failing then
    s.failing = true
    rb.log.warn("Dev Spy: screenshot not saved: " .. tostring(err))
    notify(file .. "\n" .. tostring(err), "screenshot not saved")
  end
end

-- writes a capture's PNG unless it equals the last one of its kind, judged
-- by the base64 text (crc32 takes text, and equal text is an equal PNG);
-- returns the file the line names, whether it was a repeat, or nil
local function keep(s, shot, kind, file, cause)
  local hash = rb.hash.crc32(shot.image)
  local last = s.last[kind]
  if last ~= nil and last.hash == hash then
    return last.file, true
  end
  local png = rb.codec.base64_decode(shot.image)
  local ok, err = pcall(rb.file.write, s.dir .. "/" .. file, png)
  if not ok then
    failed(s, file, err, cause)
    return nil
  end
  s.failing = false
  s.frame_bytes += #png
  s.last[kind] = { hash = hash, file = file }
  return file, false
end

-- a display as the relay's observer writes it; scale once a crop has
-- measured it
local function display_of(s, d)
  return {
    index = d.index,
    x = d.x,
    y = d.y,
    width = d.width,
    height = d.height,
    scale = s.scales[d.index],
  }
end

-- defined below: a finished screenshot starts the one waiting behind it
local shoot

local function frame(s, cause, started, input)
  s.capturing, s.last_frame = true, started
  rb.run(function()
    local ok, shot = pcall(rb.screen.capture, { max_edge = cfg.frame_edge })
    s.capturing = false
    -- a session stopped meanwhile may already be saved or discarded
    if s.closed then
      return
    end
    if s.now_cause ~= nil then
      shoot(s)
    end
    s.frame_seq += 1
    local file = string.format("frames/%06d.png", s.frame_seq)
    if not ok then
      failed(s, file, shot, cause)
      return
    end
    local kept, dup = keep(s, shot, "frame", file, cause)
    if kept == nil then
      return
    end
    if not dup then
      s.frames += 1
      s.last_file = kept
    end
    line(s, {
      kind = "frame",
      file = kept,
      dup = dup or nil,
      cause = cause,
      -- the seq of the input this screenshot was taken for, if one
      input = input,
      -- when the screenshot was asked for: the pixels are from within about
      -- 30 ms of it, while t comes after the PNG is written
      at = started - s.started,
      display = display_of(s, shot.display),
      width = shot.width,
      height = shot.height,
      cursor = shot.cursor,
      ms = now() - started,
    })
  end)
end

-- what the next screenshot is for, if one is due: an action taken now, a
-- burst or window change that has settled, or the idle heartbeat
local function due(s, t)
  if s.now_cause ~= nil then
    return s.now_cause
  end
  if s.settle_at ~= nil and t >= s.settle_at then
    return "settle"
  end
  if s.window_at ~= nil and t >= s.window_at then
    return "window"
  end
  if cfg.frame_ms > 0 and t - s.last_frame >= cfg.frame_ms * 1000 then
    return "timer"
  end
  return nil
end

-- takes the screenshot that is due, at most one at a time and, except for
-- an action or a burst's first input, one per FRAME_GAP; a trigger stays
-- pending until a screenshot starts, and one screenshot answers every
-- trigger made before it
function shoot(s)
  if s.frames_off or s.capturing then
    return
  end
  local t = now()
  local cause = due(s, t)
  if cause == nil then
    return
  end
  -- a click, an action key or the first input of a burst waits only for a
  -- running screenshot, so its frame still shows what was there; settle,
  -- window and timer screenshots keep the gap
  local now_one = cause == "click" or cause == "key" or cause == "input"
  if not now_one and t - s.last_frame < FRAME_GAP then
    return
  end
  local input = nil
  if s.now_cause ~= nil then
    -- an action shot leaves a due settle for the next shot, so the burst
    -- still gets its own frame of the result
    input = s.now_seq
    s.now_cause, s.now_seq = nil, nil
  elseif cause == "settle" then
    s.settle_at = nil
    s.burst = false
    -- the colour at each click of the burst, now that its result has drawn
    for _, click in s.clicks do
      local ok, hex = pcall(rb.screen.pixel_hex, click.x, click.y)
      if ok then
        line(s, { kind = "px_after", click = click.seq, px = hex })
      end
    end
    table.clear(s.clicks)
  end
  -- any shot answers a window change that has settled
  if s.window_at ~= nil and t >= s.window_at then
    s.window_at = nil
    s.window_shot = t
  end
  frame(s, cause, t, input)
end

-- pushes the burst's settle deadline back to SETTLE from now
local function settle(s)
  s.settle_at = now() + SETTLE
end

-- a click or action key outranks the start or a burst's first input
local RANK = { start = 1, input = 1, key = 2, click = 2 }

-- a screenshot now for the input seq, kept pending while one is running;
-- a pending click or key keeps its place
local function now_shot(s, cause, seq)
  if s.now_cause == nil or RANK[cause] > RANK[s.now_cause] then
    s.now_cause, s.now_seq = cause, seq
  end
end

-- an action is shot at once, and again when its result has settled
local function act(s, cause, seq)
  now_shot(s, cause, seq)
  s.burst = true
  settle(s)
  shoot(s)
end

-- lodash's leading and trailing edges: the first input of a burst is shot
-- at once, showing what the user reacted to, and the burst's end shows the
-- result; only a press, scroll, move or stick motion opens a burst
local function burst(s, seq, opens)
  if opens and not s.burst then
    now_shot(s, "input", seq)
  end
  s.burst = s.burst or opens
  settle(s)
  shoot(s)
end

-- a full-resolution crop centred on a click, kept inside the clicked
-- display; the line gives the region taken in screen units and the
-- display's pixels per unit
local function crop(s, seq, x, y)
  if s.frames_off then
    return
  end
  if s.cropping then
    line(s, {
      kind = "missing",
      click = seq,
      reason = "the crop of the click before was still being taken",
    })
    return
  end
  local display
  for _, d in displays() do
    if x >= d.x and x < d.x + d.width and y >= d.y and y < d.y + d.height then
      display = d
    end
  end
  if display == nil then
    return
  end
  local w = math.min(CROP, display.width)
  local h = math.min(CROP, display.height)
  local rx = math.clamp(math.floor(x - display.x - w / 2), 0, display.width - w)
  local ry =
    math.clamp(math.floor(y - display.y - h / 2), 0, display.height - h)
  local started = now()
  s.cropping = true
  rb.run(function()
    local ok, shot = pcall(rb.screen.capture, {
      display = display.index,
      region = { x = rx, y = ry, w = w, h = h },
    })
    s.cropping = false
    if s.closed then
      return
    end
    s.crop_seq += 1
    local file = string.format("crops/%06d.png", s.crop_seq)
    if not ok then
      failed(s, file, shot)
      return
    end
    local kept, dup = keep(s, shot, "crop", file)
    if kept == nil then
      return
    end
    if not dup then
      s.crops += 1
    end
    -- a crop is taken at full resolution: its width gives the display's
    -- pixels per screen unit, which frame lines then carry too
    local scale = shot.width / w
    s.scales[display.index] = scale
    line(s, {
      kind = "crop",
      file = kept,
      dup = dup or nil,
      click = seq,
      at = started - s.started,
      display = display_of(s, display),
      region = { x = display.x + rx, y = display.y + ry, w = w, h = h },
      scale = scale,
      ms = now() - started,
    })
  end)
end

function M.start(mode)
  if M.live ~= nil then
    return nil, "a session is already running"
  end
  local stamp = os.date("%Y%m%d-%H%M%S")
  local id, n = stamp .. "-" .. mode, 2
  while rb.file.exists(DATA .. "sessions/" .. id) do
    id = stamp .. "-" .. n .. "-" .. mode
    n += 1
  end
  local s = {
    id = id,
    dir = DATA .. "sessions/" .. id,
    mode = mode,
    started = now(),
    -- the first line is seq 0, as the relay's observer counts
    seq = -1,
    buf = {},
    buf_bytes = 0,
    log_bytes = 0,
    frame_bytes = 0,
    events = 0,
    frames = 0,
    frame_seq = 0,
    crop_seq = 0,
    crops = 0,
    marks = 0,
    scales = {},
    last_frame = -math.huge,
    last = {},
    path = 0,
    path_at = 0,
    clicks = {},
    window_shot = -math.huge,
    motion = { dx = 0, dy = 0, n = 0, x = 0, y = 0, at = now() },
  }
  local observe = mode == "observe"
  for _, dir in observe and { "/frames", "/crops" } or { "" } do
    local ok, err = pcall(rb.file.mkdir, s.dir .. dir)
    if not ok then
      return nil, tostring(err)
    end
  end
  local devices_ok, devices = pcall(rb.input.devices)
  s.input = devices_ok and #devices > 0 and "hardware" or "software"
  local list = displays()
  s.frames_off = not observe or #list == 0
  M.live = s
  line(s, {
    kind = "start",
    id = id,
    mode = mode,
    -- every t is ms after this moment
    epoch_ms = s.started,
    cursor = cursor(),
    goal = cfg.goal ~= "" and cfg.goal or nil,
    platform = rb.info.platform,
    input = s.input,
    displays = list,
    frame_edge = observe and cfg.frame_edge or nil,
    relay = rb.info.version,
    frames_off = s.frames_off,
  })
  if observe then
    window_line(s, rb.system.window())
    if #list == 0 then
      notify(
        "no displays found\nthis observation records input only",
        "no screenshots"
      )
    end
    now_shot(s, "start")
    shoot(s)
  end
  flush(s)
  return s
end

function M.stop(reason)
  local s = M.live
  if s == nil then
    return nil
  end
  motion(s)
  s.ended = now()
  -- a capture still running is dropped when it returns; say so now
  if s.capturing then
    line(
      s,
      { kind = "missing", reason = "the recording stopped during the capture" }
    )
  end
  if s.cropping then
    line(
      s,
      { kind = "missing", reason = "the recording stopped during a crop" }
    )
  end
  line(s, {
    kind = "end",
    reason = reason,
    seconds = (s.ended - s.started) / 1000,
    events = s.events,
    frames = s.frames,
  })
  flush(s)
  s.closed = true
  M.live = nil
  table.insert(M.unsaved, s)
  -- what the HUD shows for it after a restart, without reading the folder
  pcall(rb.file.write_json, s.dir .. "/summary.json", {
    mode = s.mode,
    started = s.started,
    ended = s.ended,
    events = s.events,
    frames = s.frames,
    log_bytes = s.log_bytes,
    frame_bytes = s.frame_bytes,
  })
  return s
end

-- every 100 ms: summed motion, a due screenshot and the batch go out;
-- returns why the session must stop, if it must
function M.tick()
  local s = M.live
  if s == nil then
    return nil
  end
  if s.motion.n > 0 and now() - s.motion.at >= MOVE_GAP then
    motion(s)
  end
  shoot(s)
  if s.broken or not flush(s) then
    return "its log could not be written"
  end
  if s.log_bytes + s.frame_bytes >= MAX_BYTES then
    return "it reached 512 MB"
  end
  if now() - s.started >= cfg.max_minutes * 60000 then
    return "it reached " .. cfg.max_minutes .. " minutes"
  end
  return nil
end

local function pad(key)
  local head = string.sub(key, 1, 3)
  return head == "Pad" or head == "Joy"
end

local COMMIT = { enter = true, escape = true, tab = true }
local MODIFIER = {
  lctrl = true,
  rctrl = true,
  lshift = true,
  rshift = true,
  lalt = true,
  ralt = true,
  lwin = true,
  rwin = true,
}

-- a click, Enter, Escape, Tab or a shortcut is an action, shot at once;
-- other keys are part of a burst
local function action_cause(key)
  if string.sub(key, 1, 5) == "Mouse" then
    return "click"
  end
  local k = string.lower(key)
  if COMMIT[k] then
    return "key"
  end
  local mods = rb.input.modifiers()
  local chord = mods.ctrl or mods.alt or mods.win
  return chord and not MODIFIER[k] and "key" or nil
end

-- the first input that names its device shows the recording is in
-- hardware mode, whatever the device list said at the start
local function device_seen(s, device)
  if device ~= nil and s.input == "software" then
    s.input = "hardware"
    line(s, { kind = "input", mode = "hardware" })
  end
end

function M.key(down, key, held, device)
  local s = M.live
  if s == nil or (s.mode == "macro" and pad(key) and not cfg.macro_pad) then
    return
  end
  motion(s)
  device_seen(s, device)
  local at = cursor()
  local fields = { kind = down and "down" or "up", key = key, cursor = at }
  if not down then
    fields.held = held
  end
  if device ~= nil then
    fields.dev = device.id
    fields.dev_name = device.nickname or device.name
  end
  if s.mode == "observe" then
    local w = rb.system.window()
    fields.wcursor = { x = at.x - w.x, y = at.y - w.y }
    -- the latest screenshot written before this input, as the relay's pre
    fields.pre = s.last_file
    if down then
      local ok, hex = pcall(rb.screen.pixel_hex, at.x, at.y)
      fields.px = ok and hex or nil
    end
  end
  line(s, fields)
  if s.mode ~= "observe" then
    return
  end
  local cause = down and action_cause(key) or nil
  if cause == "click" then
    if #s.clicks < CLICKS_MAX then
      table.insert(s.clicks, { seq = s.seq, x = at.x, y = at.y })
    end
    crop(s, s.seq, at.x, at.y)
  end
  if cause ~= nil then
    act(s, cause, s.seq)
  else
    burst(s, s.seq, down)
  end
end

function M.move(dx, dy)
  local s = M.live
  if not wanted(s, "moves") then
    return
  end
  local m = s.motion
  m.dx += dx
  m.dy += dy
  m.n += 1
  m.x, m.y = rb.system.mouse()
  if now() - m.at >= MOVE_GAP then
    motion(s)
  end
  -- path length, so a locked pointer turning a game camera still counts;
  -- a pause starts it again, so slow jitter never adds up to a burst
  local t = now()
  if t - s.path_at > MOVE_QUIET then
    s.path = 0
  end
  s.path_at = t
  s.path += math.abs(dx) + math.abs(dy)
  if s.mode == "observe" and s.path >= MOVE_MIN then
    -- the move line is still being summed, so the screenshot names no seq
    burst(s, nil, true)
  end
end

function M.scroll(delta, device)
  local s = M.live
  if not wanted(s, "scroll") then
    return
  end
  motion(s)
  device_seen(s, device)
  line(s, {
    kind = "scroll",
    delta = delta,
    cursor = cursor(),
    dev = device and device.id,
    dev_name = device and (device.nickname or device.name),
  })
  if s.mode == "observe" then
    burst(s, s.seq, true)
  end
end

function M.axis(name, value)
  local s = M.live
  if wanted(s, "pad") then
    line(s, { kind = "axis", axis = name, value = value })
    if s.mode == "observe" then
      burst(s, s.seq, true)
    end
  end
end

function M.window(w)
  local s = M.live
  if s ~= nil and s.mode == "observe" then
    window_line(s, w)
    -- its own deadline, which later changes do not push back
    s.window_at = s.window_at
      or math.max(now() + WINDOW_SETTLE, s.window_shot + WINDOW_MIN)
  end
end

function M.display(d)
  local s = M.live
  if s ~= nil and s.mode == "observe" then
    line(s, {
      kind = "display",
      index = d.index,
      x = d.x,
      y = d.y,
      width = d.width,
      height = d.height,
    })
  end
end

-- a mark label from the clipboard, cut to LABEL_MAX characters
function M.label(text)
  text = string.gsub(text or "", "%c", " ")
  local cut = utf8.offset(text, LABEL_MAX + 1)
  return cut and string.sub(text, 1, cut - 1) or text
end

-- a mark or an AI call; false when no session is running
function M.note(fields)
  local s = M.live
  if s == nil then
    return false
  end
  motion(s)
  line(s, fields)
  return true
end

-- sessions left by an earlier run (a changed keybind reloads the script)
function M.recover()
  local ok, names = pcall(rb.file.list, DATA .. "sessions")
  if not ok then
    return 0
  end
  table.sort(names)
  for _, id in names do
    local dir = DATA .. "sessions/" .. id
    local summary_ok, summary = pcall(rb.file.read_json, dir .. "/summary.json")
    if summary_ok and type(summary) == "table" then
      summary.id, summary.dir = id, dir
      table.insert(M.unsaved, summary)
    elseif rb.file.is_dir(dir) then
      -- stopped without a summary, as when the relay quit mid-recording
      local s = { id = id, dir = dir, mode = string.match(id, "%-(%a+)$") }
      local log_ok, size = pcall(rb.file.size, dir .. "/events.jsonl")
      s.log_bytes = log_ok and size or 0
      s.frame_bytes, s.frames = 0, 0
      for _, sub in { "frames", "crops" } do
        local list_ok, files = pcall(rb.file.list, dir .. "/" .. sub)
        for _, name in list_ok and files or {} do
          local path = dir .. "/" .. sub .. "/" .. name
          local size_ok, bytes = pcall(rb.file.size, path)
          s.frames += sub == "frames" and 1 or 0
          s.frame_bytes += size_ok and bytes or 0
        end
      end
      table.insert(M.unsaved, s)
    end
  end
  return #M.unsaved
end

-- names Windows keeps for devices, with or without an extension
local RESERVED = { con = true, prn = true, aux = true, nul = true }
for i = 1, 9 do
  RESERVED["com" .. i] = true
  RESERVED["lpt" .. i] = true
end

-- letters, digits, spaces, dots, underscores and dashes, at most 64, with no
-- dot or space at either end, so a name never makes a folder or fails on
-- Windows
function M.clean(name)
  local cleaned = string.gsub(name or "", "[^%w %._%-]", "")
  cleaned = string.sub(string.match(cleaned, "^[%s%.]*(.-)$"), 1, 64)
  cleaned = string.match(cleaned, "^(.-)[%s%.]*$")
  if RESERVED[string.lower(string.match(cleaned, "^[^%.]*"))] then
    cleaned ..= "_"
  end
  return cleaned
end

local function forget(s)
  local i = table.find(M.unsaved, s)
  if i ~= nil then
    table.remove(M.unsaved, i)
  end
end

-- the log's keys, buttons, movement and scrolling as a saved macro.
-- Playback moves the pointer only by relative steps (it ignores moveto), so
-- each step is the difference between two logged pointer positions, and the
-- macro starts where the recording started.
local function write_macro(path, name)
  local log = path .. "/events.jsonl"
  if rb.file.size(log) > MACRO_SCRIPT_MAX then
    return nil, "the log is over 4 MB"
  end
  -- actions keep their time; delays are worked out once the ones left out
  -- are known. open: each key's press still waiting for its release
  local actions, at, open, dropped = {}, nil, {}, {}
  local function add(action, t)
    action.t = t
    table.insert(actions, action)
    return action
  end
  local function move_to(c, t)
    if at ~= nil and (c.x ~= at.x or c.y ~= at.y) then
      add({ action = "move", dx = c.x - at.x, dy = c.y - at.y }, t)
    end
    at = c
  end
  for text in string.gmatch(rb.file.read(log), "[^\n]+") do
    local e = rb.json.parse(text)
    if e.kind == "start" then
      at = e.cursor
    elseif e.kind == "move" then
      move_to(e.cursor, e.t)
    elseif e.kind == "own" then
      -- the modifiers of Dev Spy's own chord: their press and release are
      -- left out of the macro
      for _, key in e.keys do
        if open[key] ~= nil then
          open[key].skip = true
          open[key] = nil
          dropped[key] = true
        end
      end
    elseif e.kind == "down" or e.kind == "up" then
      -- a click lands where it was made, even with no movement recorded
      if string.sub(e.key, 1, 5) == "Mouse" then
        move_to(e.cursor, e.t)
      end
      if e.kind == "up" and dropped[e.key] then
        dropped[e.key] = nil
      else
        local action = add({ action = e.kind, code = e.key }, e.t)
        open[e.key] = e.kind == "down" and action or nil
      end
    elseif e.kind == "scroll" then
      add({ action = "scroll", amount = e.delta }, e.t)
    end
  end
  -- a key still down when the recording stopped is let go at the end
  local ended = #actions > 0 and actions[#actions].t or 0
  for key, down in open do
    if not down.skip then
      add({ action = "up", code = key }, ended)
    end
  end
  local kept, last = {}, nil
  for _, action in actions do
    if not action.skip then
      action.delay = last and action.t - last or 0
      last = action.t
      action.t, action.skip = nil, nil
      table.insert(kept, action)
    end
  end
  actions = kept
  if #actions == 0 then
    return nil, "no keys, buttons or movement to replay"
  end
  local file = path .. ".luau"
  rb.file.write(file, rb.macro.to_script(actions, name))
  return file
end

-- the name a save would use: a number is added while the name is taken
function M.free(target, name)
  local base = DATA .. target .. "/" .. name
  local path, n = base, 2
  while rb.file.exists(path) or rb.file.exists(path .. ".luau") do
    path = base .. "-" .. n
    n += 1
  end
  return string.sub(path, #DATA + #target + 2)
end

-- moves the recording's folder to observations/<name> or macros/<name> in
-- the data folder, named as free() shows; macros/ also gets <name>.luau.
-- Returns the folder, then the .luau or why it was not written.
function M.save(s, target, name)
  name = M.clean(name)
  if name == "" then
    name = s.id
  end
  local path = DATA .. target .. "/" .. M.free(target, name)
  rb.file.mkdir(DATA .. target)
  rb.file.move(s.dir, path)
  forget(s)
  if target ~= "macros" then
    return path
  end
  local ok, file, why = pcall(write_macro, path, name)
  if not ok then
    return path, nil, tostring(file)
  end
  return path, file, why
end

function M.discard(s)
  forget(s)
  local ok, err = pcall(rb.file.rmdir, s.dir)
  if not ok then
    rb.log.warn("Dev Spy: " .. s.dir .. " not removed: " .. tostring(err))
  end
end

return M