Files
browser/src/script/Recorder.zig
Francis Bouvier 142c940b21 agent: add REPL /save command for recorded sessions
Add a REPL-only `/save [filename.lp]` command that persists the current
  interactive session as PandaScript without requiring the user to start the
  agent with `-i <script>`. The command records the same replayable actions as
  the existing script recorder, but keeps them in memory until the user chooses
  to save.

  Functional behavior:

  - During a REPL session, record replayable browser actions into an in-memory
    script buffer.
  - Manual slash commands are recorded through the same PandaScript formatting
    and filtering rules used by file recording.
  - Natural-language turns record their prompt as a `# ...` comment only when
    the LLM turn produces at least one successful replayable tool call.
  - Failed LLM tool calls are skipped, and repeated successful `/extract` calls
    keep only the last successful extract, matching the existing recorder logic.
  - `/save filename.lp` writes the current in-memory recording to that file.
  - Bare `/save` creates a random `session-<hex>.lp` file on first save.
  - If the first save targets an existing file, prompt with the existing numbered
    TTY picker and ask whether to replace or append.
  - After the first successful save, the REPL session is locked to that filename:
    later `/save` or `/save same-file.lp` appends to the same file without
    prompting, while `/save other-file.lp` is rejected.
  - After each successful save, reset the in-memory recorder so future saves only
    append actions entered since the previous save.
  - On any `/save` error or cancellation, keep the in-memory recorder intact so
    the user can retry without losing captured actions.
  - Restrict `/save` filenames to local file names, not paths, to keep behavior
    scoped to the current directory.

  Code changes:

  - Add `Recorder.Memory`, an in-memory recorder that shares the existing
    `Command.isRecorded`, `Command.format`, comment formatting, and `LP_*`
    reverse-substitution behavior with file recording.
  - Add `Recorder.Memory.reset()` so `/save` can clear only successfully saved
    deltas.
  - Add `/save` to the REPL meta command table and help/completion surface.
  - Add `save_buffer` and `save_path` state to `Agent`.
  - Feed manual REPL tool calls into `save_buffer` alongside the existing optional
    file recorder.
  - Extend LLM turn recording with a `capture_for_save` flag so natural-language
    REPL turns can be captured without affecting non-REPL script/self-heal paths.
  - Implement `/save` handling in `Agent`:
    - parse and validate the optional filename,
    - choose replace/append for existing first-save targets,
    - remember the first successful save path,
    - enforce the single-destination rule for the rest of the session,
    - append later deltas by default,
    - commit remembered path state only after a successful file write.
  - Add focused coverage for the memory recorder’s filtering and reset behavior.
2026-05-29 13:32:53 +02:00

498 lines
18 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const testing = @import("../testing.zig");
const Command = @import("command.zig").Command;
const Recorder = @This();
allocator: std.mem.Allocator,
/// Open append-mode handle while recording is active. Becomes null when a
/// write fails mid-session and the recorder self-disables; `isActive()`
/// reflects this.
file: ?std.fs.File,
/// Path of the active recording, owned by the Recorder.
path: []const u8,
/// Number of lines successfully appended since init. Bumped only on success
/// so callers see the actual file line count, not the attempt count.
lines: u32,
/// Reused between writes so each line doesn't alloc/free.
buf: std.Io.Writer.Allocating,
/// Reset per write — backs short-lived scrub allocations so the first
/// recorded command pays the page setup and the rest reuse the bump.
arena: std.heap.ArenaAllocator,
/// Append-open `sub_path` under `dir`, inserting a leading newline if the
/// file is non-empty.
pub fn init(allocator: std.mem.Allocator, dir: std.fs.Dir, sub_path: []const u8) !Recorder {
const owned_path = try allocator.dupe(u8, sub_path);
errdefer allocator.free(owned_path);
const file = try openForAppend(dir, sub_path);
return .{
.allocator = allocator,
.file = file,
.path = owned_path,
.lines = 0,
.buf = .init(allocator),
.arena = .init(allocator),
};
}
fn openForAppend(dir: std.fs.Dir, sub_path: []const u8) !std.fs.File {
const f = try dir.createFile(sub_path, .{ .truncate = false });
errdefer f.close();
try f.seekFromEnd(0);
const pos = try f.getPos();
if (pos > 0) try f.writeAll("\n");
return f;
}
pub fn deinit(self: *Recorder) void {
self.buf.deinit();
self.arena.deinit();
if (self.file) |f| f.close();
self.allocator.free(self.path);
}
pub fn isActive(self: *const Recorder) bool {
return self.file != null;
}
pub fn record(self: *Recorder, cmd: Command) void {
if (self.file == null) return;
if (!cmd.isRecorded()) return;
self.tryRecord(cmd) catch |err| self.disable(err);
}
fn tryRecord(self: *Recorder, cmd: Command) !void {
self.buf.clearRetainingCapacity();
try cmd.format(&self.buf.writer);
try self.buf.writer.writeByte('\n');
try self.writeScrubbed();
}
pub fn recordComment(self: *Recorder, comment: []const u8) void {
if (self.file == null) return;
self.tryRecordComment(comment) catch |err| self.disable(err);
}
fn tryRecordComment(self: *Recorder, comment: []const u8) !void {
self.buf.clearRetainingCapacity();
// Embedded newlines would smuggle an executable line into the script on
// replay (e.g. `# foo\n/goto https://attacker`). Emit each line of the
// comment as its own `# ` line; strip lone CRs.
var it = std.mem.splitScalar(u8, comment, '\n');
while (it.next()) |line| {
const trimmed = std.mem.trimRight(u8, line, "\r");
try self.buf.writer.writeAll("# ");
try self.buf.writer.writeAll(trimmed);
try self.buf.writer.writeByte('\n');
}
try self.writeScrubbed();
}
fn writeScrubbed(self: *Recorder) !void {
// Reverse-substitute any LP_* env-var values that snuck in as literals
// (e.g. an agent that retyped a username it saw via getUrl) so the
// recording stays portable instead of leaking the resolved secret.
// Propagate scrub OOM so the recorder disables itself rather than
// silently writing the unscrubbed buffer.
_ = self.arena.reset(.retain_capacity);
const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written());
try self.file.?.writeAll(scrubbed);
self.lines += @intCast(std.mem.count(u8, scrubbed, "\n"));
}
/// Any failure along the record path — buffer-write OOM, scrub OOM, or file
/// write — flips the recorder to inactive so subsequent calls become silent
/// no-ops and `isActive()` reflects the stopped state.
fn disable(self: *Recorder, err: anyerror) void {
log.warn(.app, "recording disabled", .{ .err = @errorName(err) });
if (self.file) |f| {
f.close();
self.file = null;
}
}
/// In-memory recorder used by the REPL `/save` command. It intentionally
/// shares the same command filter/formatter/scrubber as file recording, but
/// leaves persistence timing to the caller.
pub const Memory = struct {
allocator: std.mem.Allocator,
lines: u32,
content: std.Io.Writer.Allocating,
buf: std.Io.Writer.Allocating,
arena: std.heap.ArenaAllocator,
pub fn init(allocator: std.mem.Allocator) Memory {
return .{
.allocator = allocator,
.lines = 0,
.content = .init(allocator),
.buf = .init(allocator),
.arena = .init(allocator),
};
}
pub fn deinit(self: *Memory) void {
self.content.deinit();
self.buf.deinit();
self.arena.deinit();
}
pub fn bytes(self: *Memory) []const u8 {
return self.content.written();
}
pub fn reset(self: *Memory) void {
self.lines = 0;
self.content.clearRetainingCapacity();
self.buf.clearRetainingCapacity();
_ = self.arena.reset(.retain_capacity);
}
pub fn record(self: *Memory, cmd: Command) !void {
if (!cmd.isRecorded()) return;
self.buf.clearRetainingCapacity();
try cmd.format(&self.buf.writer);
try self.buf.writer.writeByte('\n');
try self.appendScrubbed();
}
pub fn recordComment(self: *Memory, comment: []const u8) !void {
self.buf.clearRetainingCapacity();
var it = std.mem.splitScalar(u8, comment, '\n');
while (it.next()) |line| {
const trimmed = std.mem.trimRight(u8, line, "\r");
try self.buf.writer.writeAll("# ");
try self.buf.writer.writeAll(trimmed);
try self.buf.writer.writeByte('\n');
}
try self.appendScrubbed();
}
fn appendScrubbed(self: *Memory) !void {
_ = self.arena.reset(.retain_capacity);
const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written());
try self.content.writer.writeAll(scrubbed);
self.lines += @intCast(std.mem.count(u8, scrubbed, "\n"));
}
};
// --- Tests ---
fn parseLine(arena: std.mem.Allocator, line: []const u8) Command {
return Command.parse(arena, line) catch unreachable;
}
test "record writes state-mutating commands" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "test.lp");
defer recorder.deinit();
recorder.record(parseLine(aa, "/goto https://example.com"));
recorder.record(parseLine(aa, "/click selector='Login'"));
recorder.record(parseLine(aa, "/tree"));
recorder.record(parseLine(aa, "/waitForSelector '.dashboard'"));
recorder.record(parseLine(aa, "/markdown"));
recorder.record(parseLine(aa, "/scroll y=200"));
recorder.record(parseLine(aa, "/hover selector='#menu'"));
recorder.record(parseLine(aa, "/selectOption selector='#country' value='France'"));
recorder.record(parseLine(aa, "/setChecked selector='#agree'"));
recorder.record(parseLine(aa, "/setChecked selector='#newsletter' checked=false"));
recorder.record(parseLine(aa, "/extract '{\"title\":\".title\"}'"));
recorder.recordComment("LOGIN");
const file = tmp.dir.openFile("test.lp", .{}) catch unreachable;
defer file.close();
var buf: [512]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expect(std.mem.indexOf(u8, content, "/goto 'https://example.com'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/click selector='Login'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/waitForSelector '.dashboard'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/scroll y=200\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/hover selector='#menu'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/selectOption selector='#country' value='France'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/setChecked selector='#agree'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/setChecked selector='#newsletter' checked=false\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/extract '{\"title\":\".title\"}'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "\n# LOGIN\n") != null);
// Read-only tools (tree, markdown) are gated out by isRecorded().
try std.testing.expect(std.mem.indexOf(u8, content, "/tree") == null);
try std.testing.expect(std.mem.indexOf(u8, content, "/markdown") == null);
}
test "record skips empty and comment lines" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "test2.lp");
defer recorder.deinit();
recorder.record(parseLine(aa, ""));
recorder.record(parseLine(aa, " "));
recorder.record(parseLine(aa, "# this is a comment"));
recorder.record(parseLine(aa, "/goto https://example.com"));
const file = tmp.dir.openFile("test2.lp", .{}) catch unreachable;
defer file.close();
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expectEqualStrings("/goto 'https://example.com'\n", content);
}
test "lines counter tracks successful appends" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "count.lp");
defer recorder.deinit();
recorder.record(parseLine(aa, "/goto https://example.com")); // +1
recorder.record(parseLine(aa, "/tree")); // skipped — not isRecorded()
recorder.record(parseLine(aa, "/click selector='Login'")); // +1
recorder.recordComment("a note"); // +1
try std.testing.expectEqual(@as(u32, 3), recorder.lines);
}
test "init appends to an existing file without truncating" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Seed a file with a prior line.
{
const seed = tmp.dir.createFile("script.lp", .{}) catch unreachable;
defer seed.close();
_ = seed.writeAll("/goto 'https://example.com'\n") catch unreachable;
}
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "script.lp");
defer recorder.deinit();
recorder.record(parseLine(aa, "/click selector='Login'"));
try std.testing.expect(recorder.isActive());
try std.testing.expectEqualStrings("script.lp", recorder.path);
const file = tmp.dir.openFile("script.lp", .{}) catch unreachable;
defer file.close();
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expect(std.mem.indexOf(u8, content, "/goto 'https://example.com'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "/click selector='Login'\n") != null);
// The prior line must precede the appended line.
const prior = std.mem.indexOf(u8, content, "/goto").?;
const appended = std.mem.indexOf(u8, content, "/click").?;
try std.testing.expect(prior < appended);
}
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
extern fn unsetenv(name: [*:0]u8) c_int;
test "recordComment scrubs literal LP_* values back to placeholders" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const var_name = "LP_RECORDER_COMMENT_TEST";
const var_value = "topsecret";
_ = setenv(@constCast(var_name), @constCast(var_value), 1);
defer _ = unsetenv(@constCast(var_name));
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "scrub.lp");
defer recorder.deinit();
recorder.recordComment("a user noted that their password is topsecret");
const file = tmp.dir.openFile("scrub.lp", .{}) catch unreachable;
defer file.close();
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
try std.testing.expectEqualStrings(
"# a user noted that their password is $LP_RECORDER_COMMENT_TEST\n",
buf[0..n],
);
}
test "recordComment splits embedded newlines into separate comment lines" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "multi.lp");
defer recorder.deinit();
// An attacker-controlled comment trying to smuggle a command must not
// produce an executable line on replay.
recorder.recordComment("note\n/goto https://attacker\r\nmore");
const file = tmp.dir.openFile("multi.lp", .{}) catch unreachable;
defer file.close();
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
try std.testing.expectEqualStrings(
"# note\n# /goto https://attacker\n# more\n",
buf[0..n],
);
}
test "record disables recorder on write failure" {
const filter: testing.LogFilter = .init(&.{.app});
defer filter.deinit();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Open the file read-only so writeAll fails with `error.NotOpenForWriting`.
// Struct literal (not `init`) because only this test needs to inject a
// read-only handle to exercise the failure path.
const file = blk: {
_ = tmp.dir.createFile("ro.lp", .{}) catch unreachable;
break :blk tmp.dir.openFile("ro.lp", .{ .mode = .read_only }) catch unreachable;
};
var recorder: Recorder = .{
.allocator = std.testing.allocator,
.file = file,
.path = try std.testing.allocator.dupe(u8, "test.lp"),
.lines = 0,
.buf = .init(std.testing.allocator),
.arena = .init(std.testing.allocator),
};
defer recorder.deinit();
var test_arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer test_arena.deinit();
const aa = test_arena.allocator();
try std.testing.expect(recorder.isActive());
recorder.record(parseLine(aa, "/goto https://example.com"));
try std.testing.expect(!recorder.isActive());
try std.testing.expectEqual(@as(u32, 0), recorder.lines);
// Subsequent calls are silent no-ops, not silent successes.
recorder.record(parseLine(aa, "/click selector='Login'"));
recorder.recordComment("note");
try std.testing.expectEqual(@as(u32, 0), recorder.lines);
}
test "init creates the file if missing" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var recorder: Recorder = try .init(std.testing.allocator, tmp.dir, "fresh.lp");
defer recorder.deinit();
recorder.record(parseLine(aa, "/goto https://example.com"));
const file = tmp.dir.openFile("fresh.lp", .{}) catch unreachable;
defer file.close();
var buf: [128]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
try std.testing.expectEqualStrings("/goto 'https://example.com'\n", buf[0..n]);
}
test "record and parse: triple-quote round-trip" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "triple.lp");
defer recorder.deinit();
const cmd_str = "/extract '{\n \"title\": \"span.title\",\n \"desc\": \"p.description\"\n}'";
const original_cmd = parseLine(aa, cmd_str);
recorder.record(original_cmd);
const file = tmp.dir.openFile("triple.lp", .{}) catch unreachable;
defer file.close();
var buf: [512]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
var iter: lp.script.Iterator = .init(aa, content);
const entry = (try iter.next()).?;
const parsed_cmd = entry.command;
try std.testing.expectEqualStrings("extract", parsed_cmd.tool_call.name());
const original_val = original_cmd.tool_call.args.?.object.get("schema").?.string;
const parsed_val = parsed_cmd.tool_call.args.?.object.get("schema").?.string;
try std.testing.expectEqualStrings(original_val, parsed_val);
}
test "memory recorder mirrors file recorder filtering" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var memory: Memory = .init(std.testing.allocator);
defer memory.deinit();
try memory.record(parseLine(aa, "/goto https://example.com"));
try memory.record(parseLine(aa, "/tree"));
try memory.record(parseLine(aa, "/click selector='Login'"));
try memory.recordComment("search for login");
try std.testing.expectEqualStrings(
"/goto 'https://example.com'\n/click selector='Login'\n# search for login\n",
memory.bytes(),
);
try std.testing.expectEqual(@as(u32, 3), memory.lines);
memory.reset();
try std.testing.expectEqualStrings("", memory.bytes());
try std.testing.expectEqual(@as(u32, 0), memory.lines);
try memory.record(parseLine(aa, "/scroll y=200"));
try std.testing.expectEqualStrings("/scroll y=200\n", memory.bytes());
try std.testing.expectEqual(@as(u32, 1), memory.lines);
}