--!strict --[[ Turns a command into something a person can read at a glance. The console used to print the wire name: `-> script.edit`, `-> instances.create`. That tells you the plugin is alive and nothing else. Watching an agent work, the interesting question is always "what is it touching" -- and the answer is sitting right there in the parameters, one field away from being shown. So `script.edit` on ServerScriptService.KillBrick becomes "Edit KillBrick", and `instances.create` with eight children becomes "Create 8 instances in Workspace". The subject is what the user recognises; the op is what the protocol needed. Three rules hold this together: Name the subject, not the path. "KillBrick" is what someone called their script; "ServerScriptService.Systems.KillBrick" is where it lives, and it buries the recognisable part behind the part that never changes. Count when there are several. "Edit 4 scripts" is worth reading. Listing four paths in a 76-pixel-wide log line is not. Never invent. An unknown op falls back to a tidied version of its own name rather than a guess, because a console that lies about what ran is worse than one that reads awkwardly. ]] local Phrase = {} -- The tail of a dotted path is the bit a human named. Everything before it is -- Roblox's filing system. local function leaf(path: any): string? if typeof(path) ~= "string" or path == "" then return nil end local last = string.match(path, "([^%.]+)$") if last == nil or last == "" then return nil end -- Array indexing survives into the name -- "Part[3]" is meaningfully -- different from "Part" when there are several siblings. return last end local function count(value: any): number if typeof(value) ~= "table" then return 0 end return #(value :: { any }) end --[[ "1 script" / "4 scripts", without the caller assembling it each time. ]] local function plural(n: number, singular: string, pluralForm: string?): string local word = if n == 1 then singular else (pluralForm or (singular .. "s")) return string.format("%d %s", n, word) end --[[ Describes a list of paths: the name when there is one, a count when there are several. This is the shape most batch tools take. ]] local function subjectOf(paths: any, noun: string): string local total = count(paths) if total == 1 then local name = leaf((paths :: { any })[1]) if name ~= nil then return name end end if total > 1 then return plural(total, noun) end return noun end --[[ Capitalises an op fragment for the fallback. `snapshots` -> `Snapshots`. ]] local function titleCase(value: string): string return (string.upper(string.sub(value, 1, 1)) .. string.sub(value, 2)) end type Describer = (params: { [string]: any }) -> string --[[ One entry per op. Each reads its own parameters, because what identifies a call differs completely between them: a script edit is about a path, a playtest is about a mode, a find is about what was searched for. ]] local DESCRIBERS: { [string]: Describer } = { ["sync.scan"] = function(params) return if params.revisions == false then "Check script layout for sync" else "Scan scripts for sync" end, ["sync.read"] = function(params) return "Read " .. subjectOf(params.paths, "script") .. " for sync" end, ["sync.apply"] = function(params) local total = count(params.writes) + count(params.creates) + count(params.deletes) + count(params.moves) return string.format("Sync %d change%s from files", total, if total == 1 then "" else "s") end, ["sync.shape"] = function() return "Check script layout for sync" end, ["sync.revisions"] = function() return "Check script revisions" end, ["sync.changes"] = function() return "Check for script changes" end, ["sync.stop"] = function() return "Stop sync tracking" end, ["sync.log"] = function() return "Sync note" end, ["sync.classes"] = function(params) return "List classes in " .. (leaf(params.path) or "tree") end, ["sync.export"] = function(params) return "Export " .. (leaf(params.path) or "tree") .. " to a build file" end, ["sync.build"] = function(params) local spec = params.spec local name = if typeof(spec) == "table" then spec.name or spec.className else nil return "Build " .. tostring(name or "tree") .. " from a build file" end, ["script.read"] = function(params) return "Read " .. subjectOf(params.paths, "script") end, ["script.edit"] = function(params) local edits = params.edits local total = count(edits) if total == 1 then local name = leaf((edits :: { any })[1].path) if name ~= nil then return "Edit " .. name end end -- Several edits often land on one script, and "Edit 3 scripts" would be -- wrong when it is three changes to one file. local seen: { [string]: boolean } = {} local distinct = 0 for _, edit in (edits or {}) :: { { [string]: any } } do local name = leaf(edit.path) if name ~= nil and not seen[name] then seen[name] = true distinct += 1 end end if distinct == 1 then for name in seen do return string.format("Edit %s (%s)", name, plural(total, "change")) end end return string.format("Edit %s", plural(distinct, "script")) end, ["script.create"] = function(params) local scripts = params.scripts if count(scripts) == 1 then local one = (scripts :: { any })[1] local className = if typeof(one.className) == "string" then one.className else "Script" return string.format("Create %s %s", one.name or "script", className) end return "Create " .. plural(count(scripts), "script") end, ["script.grep"] = function(params) local pattern = if typeof(params.pattern) == "string" then params.pattern else "?" return string.format('Search scripts for "%s"', pattern) end, --[[ Names the class, not just whatever the caller happened to call the thing. "Create MainMenu in StarterGui" was true and useless -- a name is whatever someone typed, and it says nothing about what actually landed in the place. `className` is required by the tool itself, so it is always there; the chosen name rides along in quotes when it was given, the way script.grep already quotes a search term. ]] ["instances.create"] = function(params) local instances = params.instances local total = count(instances) if total == 1 then local one = (instances :: { any })[1] local className = if typeof(one.className) == "string" and one.className ~= "" then one.className else "instance" local subject = if typeof(one.name) == "string" and one.name ~= "" then string.format('%s "%s"', className, one.name) else className local where = leaf(one.parent) return if where ~= nil then string.format("Create %s in %s", subject, where) else "Create " .. subject end local where = if total > 0 then leaf((instances :: { any })[1].parent) else nil return if where ~= nil then string.format("Create %s in %s", plural(total, "instance"), where) else "Create " .. plural(total, "instance") end, ["instances.modify"] = function(params) local targets = params.targets -- Each target carries its own path list, so the honest count is the -- total across them rather than the number of entries. local total = 0 local onlyName: string? = nil --[[ Which properties/attributes actually changed, not just how many instances did. "Modify Sword" said something happened and nothing about what; a caller watching for "did the color change" had no way to tell this apart from a rename or an attribute flip. ]] local fields: { string } = {} local seenFields: { [string]: boolean } = {} local function noteFields(bag: any) if typeof(bag) ~= "table" then return end for key in bag :: { [string]: any } do if not seenFields[key] then seenFields[key] = true table.insert(fields, key) end end end for _, target in (targets or {}) :: { { [string]: any } } do local paths = count(target.paths) total += paths if paths >= 1 and onlyName == nil then onlyName = leaf((target.paths :: { any })[1]) end noteFields(target.properties) noteFields(target.attributes) end if total == 1 and onlyName ~= nil then if #fields == 1 then return string.format("Set %s on %s", fields[1], onlyName) elseif #fields > 1 then return string.format("Modify %s (%s)", onlyName, table.concat(fields, ", ")) end return "Modify " .. onlyName end if #fields == 1 then return string.format("Set %s on %s", fields[1], plural(total, "instance")) end return "Modify " .. plural(total, "instance") end, ["instances.delete"] = function(params) return "Delete " .. subjectOf(params.paths, "instance") end, ["instances.move"] = function(params) local items = params.items local total = count(items) local cloning = total > 0 and (items :: { any })[1].mode == "clone" local verb = if cloning then "Clone" else "Move" if total == 1 then local one = (items :: { any })[1] local what = leaf(one.path) local where = leaf(one.to) -- A rename in the same call changes what actually ends up in the -- place; "Clone Sword to Backpack" while it lands as "GoldSword" -- described an instance that was never created under that name. local rename = if typeof(one.name) == "string" and one.name ~= "" then one.name else nil if what ~= nil and where ~= nil then local base = string.format("%s %s to %s", verb, what, where) return if rename ~= nil then base .. " as " .. rename else base end if what ~= nil then return if rename ~= nil then string.format("%s %s as %s", verb, what, rename) else verb .. " " .. what end end return string.format("%s %s", verb, plural(total, "instance")) end, ["discover.tree"] = function(params) local where = leaf(params.path) return if where ~= nil then "Browse " .. where else "Browse the place" end, ["discover.inspect"] = function(params) --[[ `inspect` (no explicit `properties`, not concise detail) and `modify` (when setting properties) both probe a class before doing the thing the caller actually asked for -- reading the properties that matter for it, or typing the values it is about to write. That probe is a second, genuine call to this same op, and unmarked it logged identically to the read it precedes: two lines reading "Inspect X" back to back, indistinguishable from asking twice by mistake. ]] if params.probeOnly == true then return "Check the class of " .. subjectOf(params.paths, "instance") end return "Inspect " .. subjectOf(params.paths, "instance") end, ["discover.find"] = function(params) if typeof(params.tag) == "string" and params.tag ~= "" then return string.format('Find things tagged "%s"', params.tag) end if typeof(params.nameContains) == "string" and params.nameContains ~= "" then return string.format('Find "%s"', params.nameContains) end if typeof(params.className) == "string" and params.className ~= "" then return string.format("Find every %s", params.className) end return "Search the place" end, ["debug.set"] = function(params) local breakpoints = params.breakpoints local total = count(breakpoints) if total == 1 then local one = (breakpoints :: { any })[1] local name = leaf(one.path) if name ~= nil then return string.format("Break on %s:%s", name, tostring(one.line)) end end return "Set " .. plural(total, "breakpoint") end, ["debug.clear"] = function(params) local name = leaf(params.path) return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints" end, ["debug.remotes"] = function() return "Observe playtest RemoteEvent traffic" end, ["debug.watch"] = function(params) return "Watch changes to " .. (leaf(params.path) or "an instance") end, ["debug.collisions"] = function(params) return "Watch collisions on " .. (leaf(params.path) or "an instance") end, ["debug.snapshots"] = function() return "Read what the breakpoints caught" end, ["debug.exceptions"] = function(params) return string.format("Break on errors: %s", tostring(params.mode or "Unhandled")) end, ["perf.console"] = function(params) if typeof(params.level) == "string" and params.level ~= "" then return string.format("Read the %s log", params.level) end if typeof(params.pattern) == "string" and params.pattern ~= "" then return string.format('Search output for "%s"', params.pattern) end return "Read the output log" end, ["perf.snapshot"] = function() return "Measure performance" end, ["perf.profile"] = function(params) return string.format("Profile scripts for %ss", tostring(params.seconds or 5)) end, ["perf.coverage"] = function(params) local total = count(params.enable) if total > 0 then return "Instrument " .. plural(total, "script") end return "Read line coverage" end, ["playtest.control"] = function(params) local op = tostring(params.op or "state") --[[ `stop` ends a test by polling this from the *surviving* session every 400ms until the teardown settles, sometimes five or six times in two seconds. Labelled "Check the playtest" like an agent's own status check, that read as unexplained repetition with no `stop` line anywhere to explain it -- the actual stop went to the session that is by then gone, and its console went with it. This name is why: waiting for the teardown it just started, not asking again for no reason. ]] if op == "state" and params.waitingForStop == true then return "Wait for the playtest to stop" end if op == "play" then return "Start a playtest" elseif op == "run" then return "Run the place" elseif op == "multiplayer" then return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player")) elseif op == "addPlayers" then return string.format("Add %s to the test", plural(tonumber(params.players) or 1, "player")) elseif op == "stop" then return "Stop the playtest" elseif op == "endTest" then return "End the test" end return "Check the playtest" end, ["exec.run"] = function(params) local source = if typeof(params.source) == "string" then params.source else "" --[[ Says what the code is about, not what its first line says. This used to show the opening line verbatim, which produced entries like "Run: local GenerationService = game:GetServi..." -- a fragment of a variable declaration, cut mid-word, telling the reader nothing except that some code ran. The declaration is never the point of a snippet; it is the setup before it. So the services it touches stand in for it. That is the one thing a snippet reliably says about itself, it is short, and it is what someone glancing at the console actually wants: which part of Studio is being poked. ]] local services: { string } = {} local seen: { [string]: boolean } = {} for name in string.gmatch(source, 'GetService%("([%w_]+)"%)') do if not seen[name] then seen[name] = true table.insert(services, name) end end local lines = 0 for _ in string.gmatch(source, "[^\r\n]+") do lines += 1 end if #services == 1 then return "Run Luau on " .. services[1] elseif #services == 2 then return string.format("Run Luau on %s and %s", services[1], services[2]) elseif #services > 2 then return string.format("Run Luau on %s and %d more", services[1], #services - 1) end return if lines <= 1 then "Run Luau" else string.format("Run Luau (%d lines)", lines) end, ["viewport.select"] = function(params) local total = count(params.paths) if total == 0 then return "Clear the selection" end return "Select " .. subjectOf(params.paths, "instance") end, ["viewport.raycast"] = function() return "Cast a ray" end, ["viewport.pick"] = function() return "Identify the instance at a viewport point" end, ["exec.result"] = function() return "Retrieve a retained execution result" end, ["client.read"] = function(params) return "Read the playtest client: " .. tostring(params.op) end, ["capture.screenshot"] = function() return "Take a screenshot" end, ["capture.playtestId"] = function() return "Capture the playtest view" end, ["capture.decode"] = function() return "Read back the capture" end, ["viewport.focus"] = function(params) local name = leaf(params.path) return if name ~= nil then "Look at " .. name else "Move the camera" end, ["viewport.camera"] = function() return "Move the camera" end, ["geometry.combine"] = function(params) local op = tostring(params.op or "union") local name = leaf(params.path) local verb = if op == "subtract" then "Cut" elseif op == "intersect" then "Intersect" else "Merge" if op == "subtract" and name ~= nil then local from = count(params.with) return string.format("Cut %s out of %s", plural(from, "shape"), name) end return if name ~= nil then string.format("%s %s", verb, name) else verb .. " parts" end, ["geometry.fragment"] = function(params) local name = leaf(params.path) return if name ~= nil then string.format("Shatter %s into %s", name, plural(tonumber(params.pieces) or 8, "piece")) else "Shatter a part" end, ["assets.insert"] = function(params) return string.format("Insert asset %s", tostring(params.assetId or "?")) end, ["world.history"] = function(params) local action = tostring(params.action or "status") if action == "status" then return "Check the undo history" end local steps = tonumber(params.steps) or 1 return string.format("%s %s", if action == "undo" then "Undo" else "Redo", plural(steps, "step")) end, ["world.collision"] = function(params) local action = tostring(params.action or "list") local group = if typeof(params.group) == "string" then params.group else "?" if action == "list" then return "List collision groups" elseif action == "create" then return "Create collision group " .. group elseif action == "assign" then return string.format("Put %s in %s", plural(count(params.paths), "instance"), group) elseif action == "remove" then return "Remove collision group " .. group end return string.format("Set %s against %s", group, tostring(params.with or "?")) end, ["geometry.mirror"] = function(params) local axis = string.upper(tostring(params.axis or "X")) local what = plural(count(params.paths), "instance") if params.copy == false then return string.format("Flip %s across %s", what, axis) end return string.format("Mirror %s across %s", what, axis) end, ["spatial.cast"] = function(params) local shape = tostring(params.shape or "ray") local target = if params.to ~= nil then tostring(params.to) else nil if target ~= nil then return string.format("Cast a %s at %s", shape, target) end return string.format("Cast a %s from %s", shape, tostring(params.from or "?")) end, ["spatial.overlap"] = function(params) local region = tostring(params.region or "box") if region == "part" then local name = leaf(params.path) return if name ~= nil then "Find what overlaps " .. name else "Find overlapping parts" end return string.format("Find parts in a %s at %s", region, tostring(params.at or "?")) end, ["audio.wire"] = function(params) return string.format("Wire %s to %s", leaf(params.from) or "?", leaf(params.to) or "?") end, ["audio.graph"] = function(params) local kind = tostring(params.kind or "world") local where = leaf(params.parent) if kind == "ui" then return "Build a UI sound" end return if where ~= nil then "Build a sound on " .. where else "Build a world sound" end, ["audio.inspect"] = function(params) local name = leaf(params.path) return if name ~= nil then "Read the audio graph in " .. name else "Read the audio graph" end, ["audio.speech"] = function(params) local lines = if typeof(params.lines) == "table" then #params.lines else 0 return string.format("Voice %s", plural(lines, "line")) end, ["character.moveTo"] = function(params) local name = leaf(params.path) return if name ~= nil then "Walk to " .. name else string.format("Walk to %s", tostring(params.to or "?")) end, ["character.act"] = function(params) local action = tostring(params.action or "jump") return titleCase(action) .. " the character" end, ["character.state"] = function() return "Check the character" end, ["studio.status"] = function() return "Check Studio" end, ["studio.ping"] = function() return "Ping" end, ["input.send"] = function(params) local steps = params.steps local total = count(steps) if total == 1 then local step = (steps :: { any })[1] local kind = tostring(step.kind or "key") if kind == "key" then local action = tostring(step.action or "tap") local verb = if action == "press" then "Hold" elseif action == "release" then "Release" else "Press" return string.format("%s %s", verb, tostring(step.key or "?")) elseif kind == "click" then return string.format("Click (%s, %s)", tostring(step.x or "?"), tostring(step.y or "?")) elseif kind == "move" then return "Move the pointer" elseif kind == "text" then return string.format('Type "%s"', tostring(step.text or "")) end end return "Send " .. plural(total, "input step") end, ["api.describe"] = function(params) local className = params.className return if typeof(className) == "string" and className ~= "" then "Look up " .. className else "Look up a class" end, ["api.classes"] = function(params) local needle = params.contains return if typeof(needle) == "string" and needle ~= "" then string.format('Search classes for "%s"', needle) else "List classes" end, ["perf.scene"] = function(params) local section = params.section return if typeof(section) == "string" and section ~= "" then "Measure the " .. section else "Measure the scene" end, --[[ Everything below was added after the first pass over this table, and each one spent time showing as the fallback -- "Data set", "Terrain fill". That form is honest but says nothing about the subject, which is the whole point of this module: the panel is watched to see WHAT an agent is touching, and 26 of 72 operations were declining to say. ]] ["data.list"] = function(params) local store = params.store if typeof(store) == "string" and store ~= "" then return string.format("List the keys in %s", store) end return "List the data stores" end, ["data.get"] = function(params) return string.format("Read %s from %s", tostring(params.key), tostring(params.store)) end, ["data.versions"] = function(params) return string.format("Read the history of %s", tostring(params.key)) end, ["data.set"] = function(params) return string.format("SAVE over %s in %s", tostring(params.key), tostring(params.store)) end, ["data.remove"] = function(params) return string.format("DELETE %s from %s", tostring(params.key), tostring(params.store)) end, ["discover.tags"] = function(params) local where = leaf(params.path) return if where ~= nil then "List the tags used in " .. where else "List every tag in use" end, ["viewport.ui"] = function() return "Check the interface for layout faults" end, ["viewport.uisnap"] = function(params) local where = leaf(params.path) return if where ~= nil then "Snapshot the interface in " .. where else "Snapshot the interface" end, ["viewport.textbounds"] = function(params) local name = leaf(params.path) return if name ~= nil then "Measure the text in " .. name else "Measure some text" end, ["perf.audit"] = function() return "Look for references that point at nothing" end, ["assets.audio"] = function(params) local keyword = params.keyword return if typeof(keyword) == "string" and keyword ~= "" then string.format('Search audio for "%s"', keyword) else "Search the audio library" end, ["assets.peek"] = function(params) return string.format("Look inside asset %s without inserting it", tostring(params.assetId)) end, ["assets.bake"] = function(params) return "Bake " .. subjectOf(params.paths, "instance") end, ["character.path"] = function(params) local target = leaf(params.toPath) return if target ~= nil then "Check the route to " .. target else "Check a route" end, ["device.network"] = function(params) local preset = params.preset return if typeof(preset) == "string" and preset ~= "" then string.format("Simulate a %s connection", preset) else "Shape the network connection" end, ["geometry.sweep"] = function(params) local name = leaf(params.path) return if name ~= nil then "Sweep the path of " .. name else "Sweep a motion volume" end, ["geometry.mesh"] = function(params) return "Read the geometry of " .. subjectOf(params.paths, "mesh") end, ["generate.model"] = function(params) local prompt = params.prompt return if typeof(prompt) == "string" and prompt ~= "" then string.format('Generate a model: "%s"', prompt) else "Generate a model" end, ["generate.segment"] = function(params) local name = leaf(params.path) return if name ~= nil then "Cut " .. name .. " into named parts" else "Segment a mesh" end, ["script.open"] = function(params) local name = leaf(params.path) return if name ~= nil then "Open " .. name .. " in the editor" else "Open a script" end, ["terrain.fill"] = function(params) --[[ The material lives inside the shapes, not beside them. Written first against a top-level `material`, which this op has never taken, so it always fell through to the bare "Fill terrain" -- seen in the panel for a call that laid 50 voxels of sand. The parameters a describer reads have to be the ones the handler reads. ]] local shapes = params.shapes if typeof(shapes) == "table" and #(shapes :: { any }) > 0 then local list = shapes :: { any } local first = list[1] local material = if typeof(first) == "table" then first.material else nil local named = typeof(material) == "string" and material ~= "" if #list == 1 then return if named then string.format("Fill terrain with %s", material) else "Fill terrain" end return if named then string.format("Fill %s, starting with %s", plural(#list, "terrain shape"), material) else string.format("Fill %s", plural(#list, "terrain shape")) end return "Fill terrain" end, ["terrain.clear"] = function() return "CLEAR terrain" end, ["terrain.replace"] = function(params) return string.format( "Replace %s terrain with %s", tostring(params.from or "one material"), tostring(params.to or "another") ) end, ["terrain.stats"] = function() return "Measure the terrain" end, ["terrain.materials"] = function(params) local action = tostring(params.action or "list") local count = if typeof(params.slots) == "table" then #params.slots else 0 if action == "set" then return string.format("Set %s", plural(count, "terrain material")) elseif action == "reset" then return string.format("Reset %s", plural(count, "terrain material")) end return "List terrain materials" end, ["device.list"] = function() return "List devices" end, ["device.set"] = function(params) local id = params.device return if typeof(id) == "string" and id ~= "" then "Emulate " .. id else "Emulate a device" end, ["device.stop"] = function() return "Stop emulating a device" end, ["device.state"] = function() return "Check the emulated device" end, } --[[ What kind of work an op is, for anything that wants to colour it. Grouped by what it does to the place rather than by which handler owns it, because that is the distinction someone watching cares about: reading is safe and constant, writing changes their game, and running executes code. ]] local KINDS: { [string]: string } = { discover = "read", client = "read", studio = "read", viewport = "read", capture = "read", instances = "write", geometry = "write", assets = "write", world = "write", exec = "run", playtest = "run", character = "run", -- Live keys and clicks into a running playtest are an action, not a read, -- and belong beside character/exec rather than defaulting to "read". input = "run", debug = "debug", perf = "debug", api = "read", device = "read", -- Casts and overlap tests only look; nothing in the group writes. spatial = "read", -- Wiring and graph building write; `audio.inspect` is carved out below. audio = "write", --[[ Absent groups defaulted to "read", which is the wrong way round for a default: a missing entry made `terrain.clear` and `data.set` -- one of which erases the map and the other of which overwrites a player's save with no undo -- show in the panel with the same weight as an inspect. A kind is a warning, so the ones that change things are named here explicitly. ]] terrain = "write", generate = "write", data = "write", -- Applies files to the place; the reads inside the group are carved out below. sync = "write", } function Phrase.kindOf(op: string, params: { [string]: any }?): string local group = string.match(op, "^([^%.]+)%.") or op -- Reads within the script group, writes outside it: a script edit changes -- the game, a read or a grep does not, and they are far apart in weight. if group == "script" then local action = string.match(op, "%.(.+)$") or "" return if action == "read" or action == "grep" then "read" else "write" end -- Same shape as the script group: one read sitting among writes. if op == "audio.inspect" then return "read" end if op == "terrain.stats" then return "read" end if op == "terrain.materials" and (params == nil or params.action == nil or params.action == "list") then return "read" end if op == "sync.scan" or op == "sync.shape" or op == "sync.revisions" or op == "sync.read" or op == "sync.changes" or op == "sync.classes" or op == "sync.export" then return "read" end return KINDS[group] or "read" end --[[ The readable form of one command. Never throws and never yields: this runs on the path that logs every call, including the ones that are about to fail, and a console that errors while describing an error would take the diagnosis with it. ]] function Phrase.of(op: string, params: { [string]: any }?): string local describer = DESCRIBERS[op] if describer ~= nil then local ok, described = pcall(describer, params or {}) if ok and typeof(described) == "string" and described ~= "" then return described end end -- Unknown op, or a describer that met a shape it did not expect. Tidy the -- wire name rather than inventing a subject for it: "instances.create" -- becomes "Instances create", which is honest about knowing no better. local group, action = string.match(op, "^([^%.]+)%.(.+)$") if group ~= nil and action ~= nil then return titleCase(group) .. " " .. action end return titleCase(op) end return Phrase