// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // 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 . //! A parsed slash command: a tool slash command, a `#`-comment, or an //! `LlmCommand` trigger (`/login`, `/logout`, `/acceptCookies`). Multi-line //! `'''…'''` blocks are assembled by the REPL before parse. const std = @import("std"); const lp = @import("lightpanda"); const BrowserTool = lp.tools.Tool; const Schema = @import("Schema.zig"); pub const ParseError = Schema.ParseError || error{ NotASlashCommand, }; const login_prompt = \\Find the login form on the current page. Fill in the credentials using \\$LP_* placeholders — the substitution happens inside the Lightpanda \\subprocess so the secret never enters your context. Do NOT call getEnv \\with a credential name (it would return the value). \\ \\Call getEnv with NO `name` argument first to see which LP_* variables \\are set (names only, values never included). Then pick: \\- Site-prefixed form (LP__) when the list shows one for \\ the current site — e.g. $LP_HN_USERNAME for news.ycombinator.com, \\ $LP_GH_TOKEN for github.com. \\- Otherwise fall back to the unprefixed $LP_USERNAME / $LP_PASSWORD \\ (or $LP_EMAIL) form. \\ \\Handle any cookie banners or popups first, then submit the form by \\clicking its submit button or pressing Enter in a filled field — there \\is no dedicated submit tool. ; const logout_prompt = \\Log out of the current site. Find the logout control — often a link or \\button labeled "Log out", "Logout", or "Sign out", possibly inside an \\account or user menu you must open first — and click it. Handle any \\confirmation prompt, then verify the logged-out state (e.g. a login link \\reappears). ; const accept_cookies_prompt = \\Find and dismiss the cookie consent banner on the current page. \\Look for "Accept", "Accept All", "I agree", or similar buttons and click them. ; pub const Command = union(enum) { tool_call: ToolCall, llm: LlmCommand, comment: void, /// An LLM-driven command: `@tagName` is the wire-format slash name, and /// each value owns its `prompt()` (sent to the model) and `description()` /// (shown in `/help`) — mirroring how `tool_call` wraps `BrowserTool`. pub const LlmCommand = enum { login, logout, acceptCookies, pub fn prompt(self: LlmCommand) []const u8 { return switch (self) { .login => login_prompt, .logout => logout_prompt, .acceptCookies => accept_cookies_prompt, }; } pub fn description(self: LlmCommand) []const u8 { return switch (self) { .login => "Log in using $LP_* credentials", .logout => "Log out of the current site", .acceptCookies => "Dismiss the cookie consent banner", }; } }; pub const ToolCall = struct { tool: BrowserTool, args: ?std.json.Value, pub fn name(self: ToolCall) [:0]const u8 { return @tagName(self.tool); } fn schema(self: ToolCall) *const Schema { return &Schema.all()[@intFromEnum(self.tool)]; } /// Skip the line when the recorded form would not round-trip: /// - no `selector` AND (tool needs one OR only locator is the /// ephemeral `backendNodeId`); /// - a string field can't be quoted unambiguously. fn isRecorded(self: ToolCall) bool { if (!self.tool.isRecorded()) return false; const s = self.schema(); const args = self.args orelse return s.required.len == 0 and !self.tool.needsLocator(); if (args != .object) return !self.tool.needsLocator(); const has_selector = args.object.contains("selector"); if (!has_selector and (self.tool.needsLocator() or args.object.contains("backendNodeId"))) return false; const positional = s.isBarePositional(args.object); var it = args.object.iterator(); while (it.next()) |entry| { if (s.skipForFormat(entry.key_ptr.*, entry.value_ptr.*)) continue; if (entry.value_ptr.* != .string) continue; const is_body = positional and std.mem.eql(u8, entry.key_ptr.*, s.required[0]); if (!Schema.quotableInline(entry.value_ptr.string, is_body)) return false; } return true; } }; pub fn isRecorded(self: Command) bool { return switch (self) { .comment => false, .llm => false, .tool_call => |tc| tc.isRecorded(), }; } pub fn producesData(self: Command) bool { return switch (self) { .tool_call => |tc| tc.tool.producesData(), else => false, }; } pub fn parse(arena: std.mem.Allocator, line: []const u8) ParseError!Command { return parseDiag(arena, line, null); } /// Same as `parse` but populates `diag` on `error.InvalidValue`. pub fn parseDiag(arena: std.mem.Allocator, line: []const u8, diag: ?*Schema.Diag) ParseError!Command { const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); if (trimmed.len == 0) return .{ .comment = {} }; if (trimmed[0] == '#') return .{ .comment = {} }; if (trimmed[0] != '/') return error.NotASlashCommand; const split = Schema.splitNameRest(trimmed[1..]) orelse return error.MissingName; inline for (std.meta.fields(LlmCommand)) |f| { if (std.ascii.eqlIgnoreCase(split.name, f.name)) { if (split.rest.len > 0) return error.MalformedKv; return .{ .llm = @field(LlmCommand, f.name) }; } } const s = Schema.findByName(split.name) orelse return error.UnknownTool; const args = try s.parseValueDiag(arena, split.rest, diag); return .{ .tool_call = .{ .tool = s.tool, .args = args } }; } /// JavaScript recorder format for `lightpanda run