<roblox version="4">
<Item class="Script" referent="RBX0">
<Properties>
<string name="Name">GenesisAI</string>
<token name="RunContext">0</token>
<ProtectedString name="Source"><![CDATA[--[[
	Genesis SaaS - Roblox Studio plugin (cloud client)

	Talks to the Genesis cloud relay over HTTPS and executes AI tool calls inside
	Studio. Studio takes no inbound connections, so the plugin long-polls the relay:

	  1. POST /plugin/ready        authenticate (Bearer token), get credit balance
	  2. GET  /plugin/poll         long-poll for the next delivery (tool_call | event)
	  3. (tool_call) run it in Studio, POST /plugin/response
	  4. (event) render assistant text / status in the chat panel
	  5. POST /plugin/chat         send the user's message (starts an agent turn)
	  6. POST /plugin/disconnect   on unload / manual stop

	The AI keys, model selection, and system prompts live only on the server — the plugin
	never sees them. Requirements: the Genesis cloud domain must be approved in the plugin's
	HTTP permission prompt (Roblox asks per-domain on first request).
]]

local HttpService = game:GetService("HttpService")
local RunService = game:GetService("RunService")

-- Cloud relay. The default is the production relay; an install can override it
-- (and must set its account token) via the plugin's chat panel.
local DEFAULT_BASE_URL = "https://genesis.tessermind.com"
local PLUGIN_VERSION = "1.2"

local function getSetting(key: string, default: any): any
	local ok, value = pcall(function()
		return plugin:GetSetting(key)
	end)
	if ok and value ~= nil then
		return value
	end
	return default
end

local function setSetting(key: string, value: any)
	pcall(function()
		plugin:SetSetting(key, value)
	end)
end

local BASE_URL = getSetting("genesis_base_url", DEFAULT_BASE_URL)
local AUTH_TOKEN = getSetting("genesis_token", "")
local MODEL_CHOICE = getSetting("genesis_model", "auto") -- "auto" => server routes by complexity
local IDLE_POLL = 0.4 -- seconds to wait when there is no work
local ERROR_BACKOFF = 2 -- seconds to wait after a failed poll

-- ===========================================================================
-- Toolbar UI
-- ===========================================================================

local toolbar = plugin:CreateToolbar("Genesis AI")
local button = toolbar:CreateButton("Genesis AI", "Open the Genesis AI chat panel", "rbxasset://textures/ui/common/robux.png")
button.ClickableWhenViewportHidden = true

-- ===========================================================================
-- Connection state
-- ===========================================================================

local instanceId = HttpService:GenerateGUID(false)
local connected = false
local generation = 0 -- bumped on connect/disconnect so stale poll loops exit

local function setStatus(text: string)
	print(("[Genesis] %s"):format(text))
end

local function updateButton()
	button:SetActive(connected)
end

-- ===========================================================================
-- HTTP helpers
-- ===========================================================================

-- Returns (ok, decodedBodyOrError).
local function httpJson(method: string, path: string, body: any?)
	return pcall(function()
		local headers: { [string]: string } = { ["Content-Type"] = "application/json" }
		if AUTH_TOKEN ~= "" then
			headers["Authorization"] = "Bearer " .. AUTH_TOKEN
		end
		local response = HttpService:RequestAsync({
			Url = BASE_URL .. path,
			Method = method,
			Headers = headers,
			Body = if body ~= nil then HttpService:JSONEncode(body) else nil,
		})
		if not response.Success then
			error(("HTTP %d %s"):format(response.StatusCode, response.StatusMessage))
		end
		if response.Body and #response.Body > 0 then
			return HttpService:JSONDecode(response.Body)
		end
		return nil
	end)
end

-- Unauthenticated request (device pairing): never sends the Bearer token, since the
-- plugin has no token yet while pairing.
local function httpJsonPublic(method: string, path: string, body: any?)
	return pcall(function()
		local response = HttpService:RequestAsync({
			Url = BASE_URL .. path,
			Method = method,
			Headers = { ["Content-Type"] = "application/json" },
			Body = if body ~= nil then HttpService:JSONEncode(body) else nil,
		})
		if not response.Success then
			error(("HTTP %d %s"):format(response.StatusCode, response.StatusMessage))
		end
		if response.Body and #response.Body > 0 then
			return HttpService:JSONDecode(response.Body)
		end
		return nil
	end)
end

-- ===========================================================================
-- Path resolution: "Workspace.Model.Part" -> Instance
-- ===========================================================================

local function resolvePath(path: string): Instance?
	if type(path) ~= "string" or path == "" then
		return nil
	end
	local segments = string.split(path, ".")
	local current: Instance = game
	for index, name in segments do
		if index == 1 then
			-- First segment is usually a service.
			local ok, service = pcall(function()
				return game:GetService(name :: any)
			end)
			if ok and service then
				current = service
			else
				local child = game:FindFirstChild(name)
				if not child then
					return nil
				end
				current = child
			end
		else
			local child = current:FindFirstChild(name)
			if not child then
				return nil
			end
			current = child
		end
	end
	return current
end

-- ===========================================================================
-- Value coercion: turn JSON-friendly input into Roblox types.
-- The target type is inferred from a property's existing value (typeof), so the
-- same machinery sets Vector3 / UDim2 / Color3 / CFrame / EnumItem generically.
-- ===========================================================================

local function num(v: any): number
	return tonumber(v) or 0
end

local function toVector3(v: any): Vector3
	if typeof(v) == "Vector3" then
		return v
	end
	if type(v) == "table" then
		if v.x ~= nil or v.X ~= nil then
			return Vector3.new(num(v.x or v.X), num(v.y or v.Y), num(v.z or v.Z))
		end
		return Vector3.new(num(v[1]), num(v[2]), num(v[3]))
	end
	return Vector3.zero
end

local function toUDim2(v: any): UDim2
	if typeof(v) == "UDim2" then
		return v
	end
	if type(v) == "table" then
		if v.scaleX ~= nil or v.ScaleX ~= nil then
			return UDim2.new(num(v.scaleX or v.ScaleX), num(v.offsetX or v.OffsetX), num(v.scaleY or v.ScaleY), num(v.offsetY or v.OffsetY))
		end
		return UDim2.new(num(v[1]), num(v[2]), num(v[3]), num(v[4]))
	end
	return UDim2.new()
end

local function toUDim(v: any): UDim
	if typeof(v) == "UDim" then
		return v
	end
	if type(v) == "table" then
		if v.scale ~= nil or v.Scale ~= nil then
			return UDim.new(num(v.scale or v.Scale), num(v.offset or v.Offset))
		end
		return UDim.new(num(v[1]), num(v[2]))
	end
	return UDim.new()
end

local function toColor3(v: any): Color3
	if typeof(v) == "Color3" then
		return v
	end
	if type(v) == "string" then
		local ok, color = pcall(Color3.fromHex, v)
		if ok then
			return color
		end
	end
	if type(v) == "table" then
		local r = num(v.r or v.R or v[1])
		local g = num(v.g or v.G or v[2])
		local b = num(v.b or v.B or v[3])
		if r > 1 or g > 1 or b > 1 then
			return Color3.fromRGB(r, g, b)
		end
		return Color3.new(r, g, b)
	end
	return Color3.new(1, 1, 1)
end

local function toCFrame(v: any): CFrame
	if typeof(v) == "CFrame" then
		return v
	end
	if type(v) == "table" then
		local pos = toVector3(v.position or v.Position or v)
		local orient = v.orientation or v.Orientation
		if orient then
			local o = toVector3(orient)
			return CFrame.new(pos) * CFrame.Angles(math.rad(o.X), math.rad(o.Y), math.rad(o.Z))
		end
		return CFrame.new(pos)
	end
	return CFrame.new()
end

local function toEnum(enumType: Enum, v: any): EnumItem?
	if typeof(v) == "EnumItem" then
		return v
	end
	if type(v) == "string" then
		local name = string.match(v, "([^.]+)$") or v
		for _, item in enumType:GetEnumItems() do
			if item.Name == name then
				return item
			end
		end
	elseif type(v) == "number" then
		for _, item in enumType:GetEnumItems() do
			if item.Value == v then
				return item
			end
		end
	end
	return nil
end

local function coerceTo(currentValue: any, input: any): any
	local t = typeof(currentValue)
	if t == "Vector3" then
		return toVector3(input)
	elseif t == "UDim2" then
		return toUDim2(input)
	elseif t == "UDim" then
		return toUDim(input)
	elseif t == "Color3" then
		return toColor3(input)
	elseif t == "CFrame" then
		return toCFrame(input)
	elseif t == "EnumItem" then
		return toEnum(currentValue.EnumType, input)
	elseif t == "number" then
		return tonumber(input) or input
	elseif t == "boolean" then
		if type(input) == "boolean" then
			return input
		end
		return input == "true" or input == 1
	end
	return input
end

-- Apply a { propertyName = value } table to an instance, coercing each value to
-- the property's existing type. Returns lists of applied / failed property names.
local function applyProps(inst: Instance, props: { [string]: any }): ({ string }, { string })
	local applied, failed = {}, {}
	for key, value in props do
		local okRead, current = pcall(function()
			return (inst :: any)[key]
		end)
		local coerced = if okRead then coerceTo(current, value) else value
		local okSet = pcall(function()
			(inst :: any)[key] = coerced
		end)
		table.insert(if okSet then applied else failed, key)
	end
	return applied, failed
end

local function pathToParent(parentPath: any): Instance
	if parentPath == nil or parentPath == "" then
		return workspace
	end
	local parent = resolvePath(parentPath)
	if not parent then
		error("parent not found: " .. tostring(parentPath))
	end
	return parent
end

-- ===========================================================================
-- Request handlers - one per server-side endpoint
-- ===========================================================================

local handlers = {}

-- Run arbitrary Luau, capturing printed output and returned values.
function handlers.execute_luau(data)
	local code = data.code
	if type(code) ~= "string" then
		error("'code' must be a string")
	end
	if type(loadstring) ~= "function" then
		error("loadstring is unavailable in this context")
	end

	local logs = {}
	local function capture(prefix: string)
		return function(...)
			local parts = {}
			for _, value in { ... } do
				table.insert(parts, tostring(value))
			end
			table.insert(logs, prefix .. table.concat(parts, "\t"))
		end
	end

	local env = setmetatable({
		print = capture(""),
		warn = capture("[warn] "),
	}, { __index = getfenv() })

	local chunk, compileErr = loadstring(code)
	if not chunk then
		error("compile error: " .. tostring(compileErr))
	end
	setfenv(chunk, env)

	local results = { pcall(chunk) }
	local ok = table.remove(results, 1)
	if not ok then
		error("runtime error: " .. tostring(results[1]))
	end

	local returns = {}
	for _, value in results do
		table.insert(returns, tostring(value))
	end

	return {
		output = table.concat(logs, "\n"),
		returns = returns,
	}
end

function handlers.get_instance_children(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	local children = {}
	for _, child in inst:GetChildren() do
		table.insert(children, {
			name = child.Name,
			className = child.ClassName,
			childCount = #child:GetChildren(),
		})
	end
	return {
		path = data.instancePath,
		className = inst.ClassName,
		children = children,
	}
end

-- A pragmatic property snapshot: identity + attributes + a probe of the most
-- commonly useful properties. Full reflection comes later (API-dump driven).
local PROBE_PROPS = {
	"Position", "Size", "CFrame", "Orientation", "Anchored", "CanCollide",
	"Transparency", "Reflectance", "Color", "Material", "Shape",
	"Enabled", "Visible", "Value", "Text", "Source",
}

function handlers.get_instance_properties(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end

	local probe = if type(data.propertyNames) == "table" then data.propertyNames else PROBE_PROPS
	local props = {}
	for _, propName in probe do
		local ok, value = pcall(function()
			return (inst :: any)[propName]
		end)
		if ok and value ~= nil then
			props[propName] = tostring(value)
		end
	end

	return {
		path = data.instancePath,
		name = inst.Name,
		className = inst.ClassName,
		fullName = inst:GetFullName(),
		attributes = inst:GetAttributes(),
		properties = props,
	}
end

-- --- build: create / mutate / delete --------------------------------------

function handlers.create_instance(data)
	if type(data.className) ~= "string" then
		error("'className' must be a string")
	end
	local parent = pathToParent(data.parentPath)
	local okNew, inst = pcall(Instance.new, data.className)
	if not okNew or typeof(inst) ~= "Instance" then
		error("cannot create '" .. tostring(data.className) .. "': " .. tostring(inst))
	end
	if data.name ~= nil then
		inst.Name = tostring(data.name)
	end
	local applied, failed = {}, {}
	if type(data.properties) == "table" then
		applied, failed = applyProps(inst, data.properties)
	end
	inst.Parent = parent
	return { path = inst:GetFullName(), className = inst.ClassName, applied = applied, failed = failed }
end

function handlers.set_properties(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	if type(data.properties) ~= "table" then
		error("'properties' must be a table")
	end
	local applied, failed = applyProps(inst, data.properties)
	return { path = inst:GetFullName(), applied = applied, failed = failed }
end

function handlers.delete_instance(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	local full = inst:GetFullName()
	inst:Destroy()
	return { deleted = full }
end

-- --- audio -----------------------------------------------------------------

function handlers.play_sound(data)
	local inst = resolvePath(data.instancePath)
	if not inst or not inst:IsA("Sound") then
		error("not a Sound: " .. tostring(data.instancePath))
	end
	local sound = inst :: Sound
	sound:Play()
	return { ok = true }
end

function handlers.stop_sound(data)
	local inst = resolvePath(data.instancePath)
	if not inst or not inst:IsA("Sound") then
		error("not a Sound: " .. tostring(data.instancePath))
	end
	local sound = inst :: Sound
	sound:Stop()
	return { ok = true }
end

-- --- shared helpers for the tools below -----------------------------------

local function summarize(inst: Instance)
	return { name = inst.Name, className = inst.ClassName, path = inst:GetFullName() }
end

local function asScript(path: any): Instance
	local inst = resolvePath(path)
	if not inst then
		error("instance not found: " .. tostring(path))
	end
	if not inst:IsA("LuaSourceContainer") then
		error("not a script: " .. tostring(path))
	end
	return inst
end

local function collectScripts(root: Instance): { Instance }
	local out = {}
	for _, d in root:GetDescendants() do
		if d:IsA("LuaSourceContainer") then
			table.insert(out, d)
		end
	end
	return out
end

local function plainReplaceAll(s: string, find: string, repl: string): (string, number)
	if find == "" then
		return s, 0
	end
	local out, count, idx = {}, 0, 1
	while true do
		local a, b = string.find(s, find, idx, true)
		if not a then
			table.insert(out, string.sub(s, idx))
			break
		end
		table.insert(out, string.sub(s, idx, a - 1))
		table.insert(out, repl)
		idx = b + 1
		count += 1
	end
	return table.concat(out), count
end

-- --- scripts ---------------------------------------------------------------

function handlers.get_script_source(data)
	local sc: any = asScript(data.instancePath)
	local source = sc.Source
	local lines = string.split(source, "\n")
	if data.startLine or data.endLine then
		local a = math.max(1, tonumber(data.startLine) or 1)
		local b = math.min(#lines, tonumber(data.endLine) or #lines)
		local slice = {}
		for i = a, b do
			table.insert(slice, lines[i])
		end
		return { path = sc:GetFullName(), startLine = a, endLine = b, totalLines = #lines, source = table.concat(slice, "\n") }
	end
	return { path = sc:GetFullName(), totalLines = #lines, source = source }
end

-- Compile-check written Luau WITHOUT running it: loadstring only parses + compiles, so this
-- is side-effect-free. Returns the compiler error string, or nil when the source is valid.
-- Catches the common model slips (missing call parens, stray tokens — e.g. `obj:Method and`).
-- Does NOT catch semantics/runtime (a made-up method like FindFirstPath only fails at run time).
local function syntaxErrorOf(source: string): string?
	if type(loadstring) ~= "function" then
		return nil -- unavailable in this context; skip the check rather than block the write
	end
	local chunk, err = loadstring(source)
	if chunk then
		return nil
	end
	return tostring(err)
end

-- Soft validation for script-writing tools: keep the write (so later anchored edits still
-- match), but flag a syntax error in the result so the agent sees it and self-corrects before
-- finishing — instead of leaving a broken script to surface later in Studio's Output.
local function withSyntaxCheck(sc: any, result: { [string]: any }): { [string]: any }
	local err = syntaxErrorOf(sc.Source)
	if err then
		result.syntaxError = err
		result.warning = "Script saved but has a Luau SYNTAX error — fix it with another edit before continuing: "
			.. err
	end
	return result
end

function handlers.set_script_source(data)
	local sc: any = asScript(data.instancePath)
	if type(data.source) ~= "string" then
		error("'source' must be a string")
	end
	sc.Source = data.source
	return withSyntaxCheck(sc, { path = sc:GetFullName(), bytes = #data.source })
end

function handlers.edit_script_lines(data)
	local sc: any = asScript(data.instancePath)
	if type(data.oldString) ~= "string" or type(data.newString) ~= "string" then
		error("'oldString' and 'newString' must be strings")
	end
	local source = sc.Source
	local a = string.find(source, data.oldString, 1, true)
	if not a then
		error("oldString not found in script")
	end
	sc.Source = string.sub(source, 1, a - 1) .. data.newString .. string.sub(source, a + #data.oldString)
	return withSyntaxCheck(sc, { path = sc:GetFullName(), replaced = true })
end

function handlers.insert_script_lines(data)
	local sc: any = asScript(data.instancePath)
	local lines = string.split(sc.Source, "\n")
	local at = math.clamp(tonumber(data.afterLine) or #lines, 0, #lines)
	local insertLines = string.split(tostring(data.content), "\n")
	for i = #insertLines, 1, -1 do
		table.insert(lines, at + 1, insertLines[i])
	end
	sc.Source = table.concat(lines, "\n")
	return withSyntaxCheck(sc, { path = sc:GetFullName(), totalLines = #lines })
end

function handlers.delete_script_lines(data)
	local sc: any = asScript(data.instancePath)
	local lines = string.split(sc.Source, "\n")
	local a = math.max(1, tonumber(data.startLine) or 1)
	local b = math.min(#lines, tonumber(data.endLine) or a)
	for i = b, a, -1 do
		table.remove(lines, i)
	end
	sc.Source = table.concat(lines, "\n")
	return withSyntaxCheck(sc, { path = sc:GetFullName(), totalLines = #lines })
end

function handlers.grep_scripts(data)
	local root = if data.path and data.path ~= "" then resolvePath(data.path) else game
	if not root then
		error("path not found: " .. tostring(data.path))
	end
	local caseSensitive = data.caseSensitive == true
	local needle = if caseSensitive then tostring(data.pattern) else string.lower(tostring(data.pattern))
	local maxResults = tonumber(data.maxResults) or 200
	local results = {}
	for _, sc in collectScripts(root) do
		local scAny: any = sc
		local lines = string.split(scAny.Source, "\n")
		for n, line in lines do
			local hay = if caseSensitive then line else string.lower(line)
			if string.find(hay, needle, 1, true) then
				table.insert(results, { path = sc:GetFullName(), line = n, text = line })
				if #results >= maxResults then
					return { results = results, truncated = true }
				end
			end
		end
	end
	return { results = results, truncated = false }
end

function handlers.find_and_replace_in_scripts(data)
	local root = if data.path and data.path ~= "" then resolvePath(data.path) else game
	if not root then
		error("path not found: " .. tostring(data.path))
	end
	local find = tostring(data.pattern)
	local repl = tostring(data.replacement)
	local dryRun = data.dryRun == true
	local changes, total, broken = {}, 0, 0
	for _, sc in collectScripts(root) do
		local scAny: any = sc
		local newSource, count = plainReplaceAll(scAny.Source, find, repl)
		if count > 0 then
			total += count
			local change: { [string]: any } = { path = sc:GetFullName(), replacements = count }
			if not dryRun then
				scAny.Source = newSource
				local err = syntaxErrorOf(newSource)
				if err then
					broken += 1
					change.syntaxError = err
				end
			end
			table.insert(changes, change)
		end
	end
	local result: { [string]: any } = { dryRun = dryRun, totalReplacements = total, files = changes }
	if broken > 0 then
		result.warning = ("%d script(s) now have a Luau SYNTAX error after this replace — fix them before continuing (see files[].syntaxError)."):format(
			broken
		)
	end
	return result
end

-- --- query / search --------------------------------------------------------

function handlers.get_descendants(data)
	local root = resolvePath(data.instancePath)
	if not root then
		error("instance not found: " .. tostring(data.instancePath))
	end
	local maxDepth = tonumber(data.maxDepth)
	local classFilter = data.classFilter
	local out = {}
	local function walk(node: Instance, depth: number)
		for _, child in node:GetChildren() do
			if (not classFilter) or child:IsA(classFilter) then
				table.insert(out, summarize(child))
			end
			if (not maxDepth) or depth < maxDepth then
				walk(child, depth + 1)
			end
		end
	end
	walk(root, 1)
	return { path = root:GetFullName(), count = #out, descendants = out }
end

function handlers.search_objects(data)
	local root = if data.root and data.root ~= "" then resolvePath(data.root) else game
	if not root then
		error("root not found: " .. tostring(data.root))
	end
	local searchType = data.searchType or "name"
	local query = tostring(data.query or "")
	local lowerQuery = string.lower(query)
	local maxResults = tonumber(data.maxResults) or 200
	local out = {}
	for _, d in root:GetDescendants() do
		local match
		if searchType == "class" then
			match = string.lower(d.ClassName) == lowerQuery or d:IsA(query)
		else
			match = string.find(string.lower(d.Name), lowerQuery, 1, true) ~= nil
		end
		if match then
			table.insert(out, summarize(d))
			if #out >= maxResults then
				break
			end
		end
	end
	return { count = #out, results = out }
end

function handlers.search_by_property(data)
	local root = if data.root and data.root ~= "" then resolvePath(data.root) else game
	if not root then
		error("root not found: " .. tostring(data.root))
	end
	local propName = tostring(data.propertyName)
	local target = tostring(data.propertyValue)
	local maxResults = tonumber(data.maxResults) or 200
	local out = {}
	for _, d in root:GetDescendants() do
		local ok, value = pcall(function()
			return (d :: any)[propName]
		end)
		if ok and tostring(value) == target then
			table.insert(out, summarize(d))
			if #out >= maxResults then
				break
			end
		end
	end
	return { count = #out, results = out }
end

function handlers.get_services(_data)
	local names = {
		"Workspace", "Players", "Lighting", "ReplicatedStorage", "ReplicatedFirst",
		"ServerScriptService", "ServerStorage", "StarterGui", "StarterPack", "StarterPlayer",
		"SoundService", "Teams", "Chat", "LocalizationService", "TestService", "CollectionService",
	}
	local out = {}
	for _, n in names do
		local ok, svc = pcall(function()
			return game:GetService(n :: any)
		end)
		if ok and svc then
			table.insert(out, { name = n, className = svc.ClassName, childCount = #svc:GetChildren() })
		end
	end
	return { services = out }
end

function handlers.get_place_info(_data)
	return {
		placeId = game.PlaceId,
		gameId = game.GameId,
		name = game.Name,
		jobId = game.JobId,
		workspaceChildren = #workspace:GetChildren(),
	}
end

function handlers.get_selection(_data)
	local selection = game:GetService("Selection")
	local out = {}
	for _, inst in selection:Get() do
		table.insert(out, summarize(inst))
	end
	return { count = #out, selection = out }
end

function handlers.set_selection(data)
	local selection = game:GetService("Selection")
	local insts = {}
	for _, p in (data.paths or {}) do
		local inst = resolvePath(p)
		if inst then
			table.insert(insts, inst)
		end
	end
	selection:Set(insts)
	return { count = #insts }
end

-- --- attributes & tags -----------------------------------------------------

function handlers.set_attribute(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	inst:SetAttribute(tostring(data.attributeName), data.attributeValue)
	return { ok = true }
end

function handlers.delete_attribute(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	inst:SetAttribute(tostring(data.attributeName), nil)
	return { ok = true }
end

function handlers.get_tags(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	return { tags = inst:GetTags() }
end

function handlers.add_tag(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	inst:AddTag(tostring(data.tagName))
	return { ok = true }
end

function handlers.remove_tag(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	inst:RemoveTag(tostring(data.tagName))
	return { ok = true }
end

function handlers.get_tagged(data)
	local collectionService = game:GetService("CollectionService")
	local out = {}
	for _, inst in collectionService:GetTagged(tostring(data.tagName)) do
		table.insert(out, summarize(inst))
	end
	return { count = #out, tagged = out }
end

-- --- assets ----------------------------------------------------------------

function handlers.insert_asset(data)
	local insertService = game:GetService("InsertService")
	local assetId = tonumber(string.match(tostring(data.assetId), "%d+"))
	if not assetId then
		error("invalid assetId: " .. tostring(data.assetId))
	end
	local parent = pathToParent(data.parentPath)
	local okLoad, model = pcall(function()
		return insertService:LoadAsset(assetId)
	end)
	if not okLoad then
		error("LoadAsset failed: " .. tostring(model))
	end
	local inserted = {}
	for _, child in model:GetChildren() do
		child.Parent = parent
		table.insert(inserted, summarize(child))
	end
	model:Destroy()
	return { assetId = assetId, inserted = inserted }
end

function handlers.get_asset_info(data)
	local marketplaceService = game:GetService("MarketplaceService")
	local assetId = tonumber(string.match(tostring(data.assetId), "%d+"))
	if not assetId then
		error("invalid assetId: " .. tostring(data.assetId))
	end
	local ok, info = pcall(function()
		return marketplaceService:GetProductInfo(assetId)
	end)
	if not ok then
		error("GetProductInfo failed: " .. tostring(info))
	end
	return info
end

-- --- history (undo / redo) -------------------------------------------------

function handlers.undo(_data)
	game:GetService("ChangeHistoryService"):Undo()
	return { ok = true }
end

function handlers.redo(_data)
	game:GetService("ChangeHistoryService"):Redo()
	return { ok = true }
end

function handlers.set_history_waypoint(data)
	game:GetService("ChangeHistoryService"):SetWaypoint(tostring(data.name or "Genesis"))
	return { ok = true }
end

-- --- batch / mass operations -----------------------------------------------

function handlers.mass_create_instances(data)
	local created = {}
	for _, spec in (data.instances or {}) do
		local ok, res = pcall(handlers.create_instance, spec)
		table.insert(created, if ok then res else { error = tostring(res) })
	end
	return { created = created }
end

function handlers.mass_set_properties(data)
	local results = {}
	for _, spec in (data.updates or {}) do
		local ok, res = pcall(handlers.set_properties, spec)
		table.insert(results, if ok then res else { error = tostring(res) })
	end
	return { results = results }
end

function handlers.mass_get_property(data)
	local propName = tostring(data.propertyName)
	local out = {}
	for _, p in (data.paths or {}) do
		local inst = resolvePath(p)
		if inst then
			local ok, value = pcall(function()
				return (inst :: any)[propName]
			end)
			if ok then
				out[p] = tostring(value)
			end
		end
	end
	return { propertyName = propName, values = out }
end

function handlers.clone_instance(data)
	local inst = resolvePath(data.instancePath)
	if not inst then
		error("instance not found: " .. tostring(data.instancePath))
	end
	local parent = if data.targetParentPath then pathToParent(data.targetParentPath) else inst.Parent
	local copy = inst:Clone()
	copy.Parent = parent
	return summarize(copy)
end

-- --- output log ------------------------------------------------------------

function handlers.get_output_log(data)
	local logService = game:GetService("LogService")
	local maxEntries = tonumber(data.maxEntries) or 100
	local history = logService:GetLogHistory()
	local out = {}
	local startIdx = math.max(1, #history - maxEntries + 1)
	for i = startIdx, #history do
		local entry = history[i]
		table.insert(out, {
			message = entry.message,
			messageType = tostring(entry.messageType),
			timestamp = entry.timestamp,
		})
	end
	return { count = #out, entries = out }
end

-- ===========================================================================
-- Chat panel (dockable widget) — theme-aware, styled to match Studio
-- ===========================================================================

local Studio = settings():GetService("Studio")

local function rgb(r: number, g: number, b: number): Color3
	return Color3.fromRGB(r, g, b)
end

-- Safe theme-color lookup with a fallback (so the UI never breaks on an unknown enum).
local function tc(name: string, fr: number, fg: number, fb: number): Color3
	local ok, col = pcall(function()
		return Studio.Theme:GetColor(Enum.StudioStyleGuideColor[name])
	end)
	if ok and col then
		return col
	end
	return rgb(fr, fg, fb)
end

-- Fixed accent colors for chat roles (look good on light + dark).
local ACCENT = rgb(88, 124, 255) -- "You"
local GENESIS = rgb(64, 200, 150) -- "Genesis"
local TOOLCOL = rgb(150, 160, 180) -- tool chips
local ERRCOL = rgb(224, 108, 108) -- errors

local COL = {}
local function loadColors()
	COL.bg = tc("MainBackground", 30, 31, 37)
	COL.chat = tc("ScriptBackground", 24, 25, 30)
	COL.input = tc("InputFieldBackground", 45, 46, 54)
	COL.text = tc("MainText", 230, 230, 235)
	COL.sub = tc("SubText", 150, 152, 162)
	COL.border = tc("Border", 60, 62, 72)
	COL.btn = tc("Button", 52, 54, 62)
	COL.primary = tc("DialogMainButton", 70, 110, 200)
	COL.primaryText = tc("DialogMainButtonText", 255, 255, 255)
end
loadColors()

local widgetInfo = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 380, 560, 300, 420)
local widget = plugin:CreateDockWidgetPluginGui("GenesisAIChat", widgetInfo)
widget.Title = "Genesis AI"
widget.Name = "GenesisAIChat"

local PAD = 10
local HEADER_H = 30
local LOGIN_H = 78
local STATUS_H = 16
local INPUT_H = 40

local function corner(inst: Instance, r: number)
	local c = Instance.new("UICorner")
	c.CornerRadius = UDim.new(0, r)
	c.Parent = inst
end

local root = Instance.new("Frame")
root.Size = UDim2.fromScale(1, 1)
root.BackgroundColor3 = COL.bg
root.BorderSizePixel = 0
root.Parent = widget

-- Header: title + model selector + new-chat
local title = Instance.new("TextLabel")
title.Position = UDim2.new(0, PAD, 0, PAD)
title.Size = UDim2.new(1, -2 * PAD - 208, 0, HEADER_H)
title.BackgroundTransparency = 1
title.Font = Enum.Font.GothamBold
title.TextSize = 16
title.TextXAlignment = Enum.TextXAlignment.Left
title.TextColor3 = COL.text
title.Text = "Genesis AI"
title.Parent = root

-- Brains the user can pick. Abstract, provider-agnostic tiers — NO model/provider names
-- (the real models live only on the server). Each row: { token, label, description }.
local MODELS = {
	{ "auto", "Auto", "Automatically picks the right brain for each task. Recommended — best value." },
	{ "simple", "Simple", "Fastest and cheapest — spends credits slowly. Lower quality; good for tiny edits and questions." },
	{ "medium", "Medium", "Balanced — moderate credit cost and quality. Handles most everyday tasks well." },
	{ "smart", "Smart", "Most capable — best results, but the most expensive (spends credits fastest). Mistakes still possible." },
}
local modelIndex = 1
for i, m in ipairs(MODELS) do
	if m[1] == MODEL_CHOICE then
		modelIndex = i
	end
end
-- Normalize any stale/legacy stored value back to a known tier token.
MODEL_CHOICE = MODELS[modelIndex][1]
setSetting("genesis_model", MODEL_CHOICE)

local modelBtn = Instance.new("TextButton")
modelBtn.Position = UDim2.new(1, -PAD - 46 - 6 - 84, 0, PAD + 4)
modelBtn.Size = UDim2.new(0, 84, 0, 22)
modelBtn.Font = Enum.Font.GothamMedium
modelBtn.TextSize = 11
modelBtn.AutoButtonColor = true
modelBtn.TextColor3 = COL.text
modelBtn.BackgroundColor3 = COL.btn
modelBtn.BorderSizePixel = 0
modelBtn.Parent = root
corner(modelBtn, 5)

-- Hover tooltip describing the selected brain (speed / cost / quality).
local modelTip = Instance.new("TextLabel")
modelTip.AnchorPoint = Vector2.new(1, 0)
modelTip.Position = UDim2.new(1, -PAD, 0, PAD + 30)
modelTip.Size = UDim2.new(0, 240, 0, 0)
modelTip.AutomaticSize = Enum.AutomaticSize.Y
modelTip.BackgroundColor3 = COL.btn
modelTip.BackgroundTransparency = 0.05
modelTip.Font = Enum.Font.Gotham
modelTip.TextSize = 11
modelTip.TextColor3 = COL.text
modelTip.TextWrapped = true
modelTip.TextXAlignment = Enum.TextXAlignment.Left
modelTip.TextYAlignment = Enum.TextYAlignment.Top
modelTip.Visible = false
modelTip.ZIndex = 20
modelTip.Parent = root
corner(modelTip, 5)
do
	local tipPad = Instance.new("UIPadding")
	tipPad.PaddingTop = UDim.new(0, 6)
	tipPad.PaddingBottom = UDim.new(0, 6)
	tipPad.PaddingLeft = UDim.new(0, 8)
	tipPad.PaddingRight = UDim.new(0, 8)
	tipPad.Parent = modelTip
end

local resetBtn = Instance.new("TextButton")
resetBtn.Position = UDim2.new(1, -PAD - 46, 0, PAD + 4)
resetBtn.Size = UDim2.new(0, 46, 0, 22)
resetBtn.Font = Enum.Font.GothamMedium
resetBtn.TextSize = 11
resetBtn.AutoButtonColor = true
resetBtn.Text = "New"
resetBtn.TextColor3 = COL.text
resetBtn.BackgroundColor3 = COL.btn
resetBtn.BorderSizePixel = 0
resetBtn.Parent = root
corner(resetBtn, 5)

-- Sign out / switch account. Clears the saved token and returns to the Connect screen so a
-- different account can be linked. Only meaningful while connected → hidden until then.
local signOutBtn = Instance.new("TextButton")
signOutBtn.Position = UDim2.new(1, -PAD - 46 - 6 - 84 - 6 - 56, 0, PAD + 4)
signOutBtn.Size = UDim2.new(0, 56, 0, 22)
signOutBtn.Font = Enum.Font.GothamMedium
signOutBtn.TextSize = 11
signOutBtn.AutoButtonColor = true
signOutBtn.Text = "Sign out"
signOutBtn.TextColor3 = COL.text
signOutBtn.BackgroundColor3 = COL.btn
signOutBtn.BorderSizePixel = 0
signOutBtn.Visible = false
signOutBtn.Parent = root
corner(signOutBtn, 5)

-- "Report a problem" — opens a small overlay to send a bug report (with diagnostics) to us.
-- Only meaningful while connected (the endpoint is authenticated) → hidden until then.
local reportBtn = Instance.new("TextButton")
reportBtn.Position = UDim2.new(1, -PAD - 46 - 6 - 84 - 6 - 56 - 6 - 24, 0, PAD + 4)
reportBtn.Size = UDim2.new(0, 24, 0, 22)
reportBtn.Font = Enum.Font.GothamMedium
reportBtn.TextSize = 12
reportBtn.AutoButtonColor = true
reportBtn.Text = "\u{1F41E}" -- 🐞
reportBtn.TextColor3 = COL.text
reportBtn.BackgroundColor3 = COL.btn
reportBtn.BorderSizePixel = 0
reportBtn.Visible = false
reportBtn.Parent = root
corner(reportBtn, 5)

-- Auth panel (email + password) — visible only while logged out.
local authRow = Instance.new("Frame")
authRow.Position = UDim2.new(0, PAD, 0, PAD + HEADER_H + 6)
authRow.Size = UDim2.new(1, -2 * PAD, 0, LOGIN_H)
authRow.BackgroundTransparency = 1
authRow.Parent = root

-- One-tap account linking (device pairing): the button opens the browser to sign in
-- with Roblox or Google; the plugin then receives its token automatically. No
-- email/password or copy-paste inside Studio.
local authBtn = Instance.new("TextButton")
authBtn.Position = UDim2.new(0, 0, 0, 0)
authBtn.Size = UDim2.new(1, 0, 0, 36)
authBtn.Font = Enum.Font.GothamBold
authBtn.TextSize = 14
authBtn.AutoButtonColor = true
authBtn.Text = "Connect account"
authBtn.TextColor3 = COL.primaryText
authBtn.BackgroundColor3 = COL.primary
authBtn.BorderSizePixel = 0
authBtn.Parent = authRow
corner(authBtn, 6)

local toggleLabel = Instance.new("TextLabel")
toggleLabel.Position = UDim2.new(0, 0, 0, 44)
toggleLabel.Size = UDim2.new(1, 0, 0, 34)
toggleLabel.BackgroundTransparency = 1
toggleLabel.Font = Enum.Font.Gotham
toggleLabel.TextSize = 11
toggleLabel.TextWrapped = true
toggleLabel.TextYAlignment = Enum.TextYAlignment.Top
toggleLabel.TextColor3 = COL.sub
toggleLabel.Text = "Opens a sign-in page in your browser where you link this plugin to your account."
toggleLabel.Parent = authRow

-- Chat log
local log = Instance.new("ScrollingFrame")
log.BackgroundColor3 = COL.chat
log.BorderSizePixel = 0
log.CanvasSize = UDim2.new()
log.AutomaticCanvasSize = Enum.AutomaticSize.Y
log.ScrollBarThickness = 6
log.ScrollingDirection = Enum.ScrollingDirection.Y
log.Parent = root
corner(log, 6)
local logPad = Instance.new("UIPadding")
logPad.PaddingTop = UDim.new(0, 10)
logPad.PaddingBottom = UDim.new(0, 10)
logPad.PaddingLeft = UDim.new(0, 10)
logPad.PaddingRight = UDim.new(0, 10)
logPad.Parent = log
local logLayout = Instance.new("UIListLayout")
logLayout.SortOrder = Enum.SortOrder.LayoutOrder
logLayout.Padding = UDim.new(0, 12)
logLayout.Parent = log

-- Status line
local statusLabel = Instance.new("TextLabel")
statusLabel.Position = UDim2.new(0, PAD, 1, -(INPUT_H + PAD + STATUS_H))
statusLabel.Size = UDim2.new(1, -2 * PAD, 0, STATUS_H)
statusLabel.BackgroundTransparency = 1
statusLabel.Font = Enum.Font.Gotham
statusLabel.TextSize = 11
statusLabel.TextXAlignment = Enum.TextXAlignment.Left
statusLabel.TextColor3 = COL.sub
statusLabel.Text = "disconnected"
statusLabel.Parent = root

-- Persistent credit balance (right-aligned, shares the status row).
local creditsLabel = Instance.new("TextLabel")
creditsLabel.Position = UDim2.new(0, PAD, 1, -(INPUT_H + PAD + STATUS_H))
creditsLabel.Size = UDim2.new(1, -2 * PAD, 0, STATUS_H)
creditsLabel.BackgroundTransparency = 1
creditsLabel.Font = Enum.Font.GothamMedium
creditsLabel.TextSize = 11
creditsLabel.TextXAlignment = Enum.TextXAlignment.Right
creditsLabel.TextColor3 = GENESIS
creditsLabel.Text = ""
creditsLabel.Parent = root

-- Group digits with thousands separators (e.g. 12345 -> "12,345").
local function fmtNum(n: number): string
	local s = tostring(math.floor(n + 0.5))
	local out = ""
	local c = 0
	for i = #s, 1, -1 do
		out = s:sub(i, i) .. out
		c += 1
		if c % 3 == 0 and i > 1 then
			out = "," .. out
		end
	end
	return out
end

-- Onboarding-nudge state. The banner widget is built further down; forward-declare so
-- setCredits can drive it. The nudge shows whenever the balance is 0.
local lastCredits: number? = nil
local needsCard = false
local updateNudge: (() -> ())? = nil
-- Last error surfaced this session — attached to a bug report so we can see what broke.
local lastError: string? = nil

local function setCredits(n: any)
	if type(n) == "number" then
		lastCredits = n
		creditsLabel.Text = fmtNum(n) .. " credits"
		if updateNudge then
			updateNudge()
		end
	end
end

-- Input row
local inputBox = Instance.new("TextBox")
inputBox.Position = UDim2.new(0, PAD, 1, -(INPUT_H + PAD - 2))
inputBox.Size = UDim2.new(1, -2 * PAD - 76, 0, INPUT_H - 8)
inputBox.PlaceholderText = "Ask Genesis to build something..."
inputBox.Text = ""
inputBox.ClearTextOnFocus = false
inputBox.MultiLine = false
inputBox.Font = Enum.Font.Gotham
inputBox.TextSize = 13
inputBox.TextXAlignment = Enum.TextXAlignment.Left
inputBox.TextColor3 = COL.text
inputBox.PlaceholderColor3 = COL.sub
inputBox.BackgroundColor3 = COL.input
inputBox.BorderSizePixel = 0
inputBox.Parent = root
corner(inputBox, 6)
local inputPad = Instance.new("UIPadding")
inputPad.PaddingLeft = UDim.new(0, 10)
inputPad.PaddingRight = UDim.new(0, 10)
inputPad.Parent = inputBox

local sendBtn = Instance.new("TextButton")
sendBtn.Position = UDim2.new(1, -PAD - 66, 1, -(INPUT_H + PAD - 2))
sendBtn.Size = UDim2.new(0, 66, 0, INPUT_H - 8)
sendBtn.Font = Enum.Font.GothamBold
sendBtn.TextSize = 13
sendBtn.AutoButtonColor = true
sendBtn.Text = "Send"
sendBtn.TextColor3 = COL.primaryText
sendBtn.BackgroundColor3 = COL.primary
sendBtn.BorderSizePixel = 0
sendBtn.Parent = root
corner(sendBtn, 6)

-- Destructive-op confirmation overlay. Shown when the server flags a tool call as
-- destructive (delete, terrain clear, script wipe); the user must approve before it runs.
-- Declining returns "declined" to the agent so it can react without the op happening.
local confirmEvent = Instance.new("BindableEvent")
local confirmFrame = Instance.new("Frame")
confirmFrame.Size = UDim2.new(1, -2 * PAD, 0, 154)
confirmFrame.Position = UDim2.new(0, PAD, 0.5, -77)
confirmFrame.BackgroundColor3 = COL.input
confirmFrame.BorderSizePixel = 0
confirmFrame.Visible = false
confirmFrame.ZIndex = 50
confirmFrame.Parent = root
corner(confirmFrame, 8)
do
	local stroke = Instance.new("UIStroke")
	stroke.Color = ERRCOL
	stroke.Thickness = 1
	stroke.Parent = confirmFrame
end

local confirmText = Instance.new("TextLabel")
confirmText.Position = UDim2.new(0, 14, 0, 14)
confirmText.Size = UDim2.new(1, -28, 0, 90)
confirmText.BackgroundTransparency = 1
confirmText.Font = Enum.Font.GothamMedium
confirmText.TextSize = 13
confirmText.TextWrapped = true
confirmText.TextXAlignment = Enum.TextXAlignment.Left
confirmText.TextYAlignment = Enum.TextYAlignment.Top
confirmText.TextColor3 = COL.text
confirmText.ZIndex = 51
confirmText.Text = ""
confirmText.Parent = confirmFrame

local denyBtn = Instance.new("TextButton")
denyBtn.AnchorPoint = Vector2.new(0, 1)
denyBtn.Position = UDim2.new(0, 14, 1, -12)
denyBtn.Size = UDim2.new(0.5, -19, 0, 30)
denyBtn.Font = Enum.Font.GothamBold
denyBtn.TextSize = 13
denyBtn.Text = "Deny"
denyBtn.TextColor3 = COL.text
denyBtn.BackgroundColor3 = COL.btn
denyBtn.BorderSizePixel = 0
denyBtn.ZIndex = 51
denyBtn.Parent = confirmFrame
corner(denyBtn, 6)

local approveBtn = Instance.new("TextButton")
approveBtn.AnchorPoint = Vector2.new(1, 1)
approveBtn.Position = UDim2.new(1, -14, 1, -12)
approveBtn.Size = UDim2.new(0.5, -19, 0, 30)
approveBtn.Font = Enum.Font.GothamBold
approveBtn.TextSize = 13
approveBtn.Text = "Approve"
approveBtn.TextColor3 = rgb(255, 255, 255)
approveBtn.BackgroundColor3 = ERRCOL
approveBtn.BorderSizePixel = 0
approveBtn.ZIndex = 51
approveBtn.Parent = confirmFrame
corner(approveBtn, 6)

approveBtn.MouseButton1Click:Connect(function()
	confirmFrame.Visible = false
	confirmEvent:Fire(true)
end)
denyBtn.MouseButton1Click:Connect(function()
	confirmFrame.Visible = false
	confirmEvent:Fire(false)
end)

-- Blocks the poll-loop coroutine until the user chooses. Safe: only one tool runs at a
-- time per turn, and the server is awaiting this call's response (bounded by its timeout).
local function askConfirm(label: string): boolean
	confirmText.Text = "Genesis wants to run a destructive action:\n\n\u{2022} "
		.. label
		.. "\n\nApprove only if you asked for this."
	confirmFrame.Visible = true
	if not widget.Enabled then
		widget.Enabled = true
	end
	local choice = confirmEvent.Event:Wait()
	confirmFrame.Visible = false
	return choice == true
end

-- "Report a problem" overlay: a multi-line box to describe the issue; on send we POST it to
-- /plugin/feedback with diagnostics (model, place, last error, version) so we can triage.
local reportFrame = Instance.new("Frame")
reportFrame.Size = UDim2.new(1, -2 * PAD, 0, 214)
reportFrame.Position = UDim2.new(0, PAD, 0.5, -107)
reportFrame.BackgroundColor3 = COL.input
reportFrame.BorderSizePixel = 0
reportFrame.Visible = false
reportFrame.ZIndex = 50
reportFrame.Parent = root
corner(reportFrame, 8)
do
	local s = Instance.new("UIStroke")
	s.Color = GENESIS
	s.Thickness = 1
	s.Parent = reportFrame
end

local reportTitle = Instance.new("TextLabel")
reportTitle.Position = UDim2.new(0, 14, 0, 12)
reportTitle.Size = UDim2.new(1, -28, 0, 36)
reportTitle.BackgroundTransparency = 1
reportTitle.Font = Enum.Font.GothamBold
reportTitle.TextSize = 14
reportTitle.TextWrapped = true
reportTitle.TextXAlignment = Enum.TextXAlignment.Left
reportTitle.TextYAlignment = Enum.TextYAlignment.Top
reportTitle.TextColor3 = COL.text
reportTitle.ZIndex = 51
reportTitle.Text = "Report a problem\nTell us what went wrong — we read every report."
reportTitle.Parent = reportFrame

local reportInput = Instance.new("TextBox")
reportInput.Position = UDim2.new(0, 14, 0, 54)
reportInput.Size = UDim2.new(1, -28, 0, 86)
reportInput.BackgroundColor3 = COL.bg
reportInput.BorderSizePixel = 0
reportInput.Font = Enum.Font.Gotham
reportInput.TextSize = 13
reportInput.TextWrapped = true
reportInput.TextXAlignment = Enum.TextXAlignment.Left
reportInput.TextYAlignment = Enum.TextYAlignment.Top
reportInput.TextColor3 = COL.text
reportInput.PlaceholderText = "Describe the problem (what you asked, what happened)…"
reportInput.ClearTextOnFocus = false
reportInput.MultiLine = true
reportInput.Text = ""
reportInput.ZIndex = 51
reportInput.Parent = reportFrame
corner(reportInput, 6)
do
	local p = Instance.new("UIPadding")
	p.PaddingLeft = UDim.new(0, 8)
	p.PaddingRight = UDim.new(0, 8)
	p.PaddingTop = UDim.new(0, 6)
	p.PaddingBottom = UDim.new(0, 6)
	p.Parent = reportInput
end

local reportStatus = Instance.new("TextLabel")
reportStatus.Position = UDim2.new(0, 14, 0, 142)
reportStatus.Size = UDim2.new(1, -28, 0, 16)
reportStatus.BackgroundTransparency = 1
reportStatus.Font = Enum.Font.Gotham
reportStatus.TextSize = 11
reportStatus.TextXAlignment = Enum.TextXAlignment.Left
reportStatus.TextColor3 = COL.sub
reportStatus.ZIndex = 51
reportStatus.Text = ""
reportStatus.Parent = reportFrame

local reportCancel = Instance.new("TextButton")
reportCancel.AnchorPoint = Vector2.new(0, 1)
reportCancel.Position = UDim2.new(0, 14, 1, -12)
reportCancel.Size = UDim2.new(0.5, -19, 0, 30)
reportCancel.Font = Enum.Font.GothamBold
reportCancel.TextSize = 13
reportCancel.Text = "Cancel"
reportCancel.TextColor3 = COL.text
reportCancel.BackgroundColor3 = COL.btn
reportCancel.BorderSizePixel = 0
reportCancel.ZIndex = 51
reportCancel.Parent = reportFrame
corner(reportCancel, 6)

local reportSend = Instance.new("TextButton")
reportSend.AnchorPoint = Vector2.new(1, 1)
reportSend.Position = UDim2.new(1, -14, 1, -12)
reportSend.Size = UDim2.new(0.5, -19, 0, 30)
reportSend.Font = Enum.Font.GothamBold
reportSend.TextSize = 13
reportSend.Text = "Send"
reportSend.TextColor3 = COL.primaryText
reportSend.BackgroundColor3 = COL.primary
reportSend.BorderSizePixel = 0
reportSend.ZIndex = 51
reportSend.Parent = reportFrame
corner(reportSend, 6)

local function openReport()
	reportInput.Text = ""
	reportStatus.Text = ""
	reportSend.Text = "Send"
	reportFrame.Visible = true
	if not widget.Enabled then
		widget.Enabled = true
	end
	reportInput:CaptureFocus()
end

reportCancel.MouseButton1Click:Connect(function()
	reportFrame.Visible = false
end)
reportSend.MouseButton1Click:Connect(function()
	local msg = reportInput.Text
	if msg:gsub("%s", "") == "" then
		reportStatus.Text = "Please describe the problem first."
		return
	end
	reportSend.Text = "Sending…"
	local context = {
		model = MODEL_CHOICE,
		placeId = game.PlaceId,
		placeName = game.Name,
		lastError = lastError,
		version = PLUGIN_VERSION,
	}
	local ok = httpJson("POST", "/plugin/feedback", { message = msg, context = context })
	if ok then
		reportFrame.Visible = false
		statusLabel.Text = "thanks \u{2014} your report was sent"
	else
		reportSend.Text = "Send"
		reportStatus.Text = "Couldn't send — check your connection and try again."
	end
end)
reportBtn.MouseButton1Click:Connect(openReport)

-- Pairing panel: a plugin can't open a browser OR write the clipboard (both need the
-- RobloxScript capability, denied to plugins). So we show the sign-in link in a selectable
-- box — the user selects it, Ctrl+C, pastes in their browser, signs in, and is connected.
-- The link has the pairing code baked in, so there's no separate code to type.
local pairPanel = Instance.new("Frame")
pairPanel.Size = UDim2.new(1, -2 * PAD, 0, 172)
pairPanel.Position = UDim2.new(0, PAD, 0.5, -86)
pairPanel.BackgroundColor3 = COL.input
pairPanel.BorderSizePixel = 0
pairPanel.Visible = false
pairPanel.ZIndex = 40
pairPanel.Parent = root
corner(pairPanel, 8)
do
	local s = Instance.new("UIStroke")
	s.Color = GENESIS
	s.Thickness = 1
	s.Parent = pairPanel
end

local pairTitle = Instance.new("TextLabel")
pairTitle.Position = UDim2.new(0, 12, 0, 12)
pairTitle.Size = UDim2.new(1, -24, 0, 50)
pairTitle.BackgroundTransparency = 1
pairTitle.Font = Enum.Font.GothamMedium
pairTitle.TextSize = 12
pairTitle.TextWrapped = true
pairTitle.TextXAlignment = Enum.TextXAlignment.Left
pairTitle.TextYAlignment = Enum.TextYAlignment.Top
pairTitle.TextColor3 = COL.text
pairTitle.ZIndex = 41
pairTitle.Text = "The link below is already selected — just press Ctrl+C, paste it in your browser, and sign in:"
pairTitle.Parent = pairPanel

local linkBox = Instance.new("TextBox")
linkBox.Position = UDim2.new(0, 12, 0, 66)
linkBox.Size = UDim2.new(1, -24, 0, 50)
linkBox.BackgroundColor3 = COL.bg
linkBox.BorderSizePixel = 0
linkBox.Font = Enum.Font.Code
linkBox.TextSize = 12
linkBox.TextWrapped = true
linkBox.TextXAlignment = Enum.TextXAlignment.Left
linkBox.TextYAlignment = Enum.TextYAlignment.Top
linkBox.TextColor3 = COL.text
linkBox.ClearTextOnFocus = false
linkBox.TextEditable = true
linkBox.MultiLine = true
linkBox.ZIndex = 41
linkBox.Text = ""
linkBox.Parent = pairPanel
corner(linkBox, 6)
do
	local p = Instance.new("UIPadding")
	p.PaddingLeft = UDim.new(0, 8)
	p.PaddingRight = UDim.new(0, 8)
	p.PaddingTop = UDim.new(0, 6)
	p.PaddingBottom = UDim.new(0, 6)
	p.Parent = linkBox
end
local function selectLink()
	linkBox:CaptureFocus()
	linkBox.CursorPosition = #linkBox.Text + 1
	linkBox.SelectionStart = 1
end
linkBox.Focused:Connect(function()
	linkBox.CursorPosition = #linkBox.Text + 1
	linkBox.SelectionStart = 1
end)

local pairCancel = Instance.new("TextButton")
pairCancel.AnchorPoint = Vector2.new(1, 1)
pairCancel.Position = UDim2.new(1, -12, 1, -12)
pairCancel.Size = UDim2.new(0, 72, 0, 28)
pairCancel.Font = Enum.Font.GothamMedium
pairCancel.TextSize = 12
pairCancel.Text = "Cancel"
pairCancel.TextColor3 = COL.text
pairCancel.BackgroundColor3 = COL.btn
pairCancel.BorderSizePixel = 0
pairCancel.ZIndex = 41
pairCancel.Parent = pairPanel
corner(pairCancel, 6)

local function showPair(url: string)
	linkBox.Text = url
	pairPanel.Visible = true
	if not widget.Enabled then
		widget.Enabled = true
	end
	task.defer(selectLink) -- pre-select the link so the user only needs Ctrl+C (no Select step)
end
local function hidePair()
	pairPanel.Visible = false
end

-- Onboarding nudge: appears when the account has 0 credits (e.g. a brand-new user who
-- connected the plugin before adding a card). Points them to the dashboard to unlock the trial.
local nudge = Instance.new("Frame")
nudge.Size = UDim2.new(1, -2 * PAD, 0, 56)
nudge.Position = UDim2.new(0, PAD, 0, PAD + HEADER_H + 6)
nudge.BackgroundColor3 = COL.input
nudge.BorderSizePixel = 0
nudge.Visible = false
nudge.ZIndex = 30
nudge.Parent = root
corner(nudge, 8)
do
	local s = Instance.new("UIStroke")
	s.Color = GENESIS
	s.Thickness = 1
	s.Parent = nudge
end

-- Text-only banner: a plugin can't open a browser (OpenBrowserWindow needs RobloxScript),
-- so we show the typeable URL rather than a dead button.
local nudgeLabel = Instance.new("TextLabel")
nudgeLabel.Position = UDim2.new(0, 12, 0, 0)
nudgeLabel.Size = UDim2.new(1, -24, 1, 0)
nudgeLabel.BackgroundTransparency = 1
nudgeLabel.Font = Enum.Font.GothamMedium
nudgeLabel.TextSize = 12
nudgeLabel.TextWrapped = true
nudgeLabel.TextXAlignment = Enum.TextXAlignment.Left
nudgeLabel.TextColor3 = COL.text
nudgeLabel.ZIndex = 31
nudgeLabel.Text = ""
nudgeLabel.Parent = nudge

-- Assign the forward-declared updater now that the widget exists.
updateNudge = function()
	local show = lastCredits == 0
	nudge.Visible = show
	if show then
		if needsCard then
			nudgeLabel.Text = "You have 0 credits. Open genesis.tessermind.com, add a card, and get 300 free credits."
		else
			nudgeLabel.Text = "You're out of credits. Open genesis.tessermind.com to top up and keep building."
		end
	end
end

-- Lay out the chat area between the header/auth panel and the input row.
local function relayout()
	local top = PAD + HEADER_H + 6
	if authRow.Visible then
		top = top + LOGIN_H + 6
	end
	log.Position = UDim2.new(0, PAD, 0, top)
	log.Size = UDim2.new(1, -2 * PAD, 1, -(top + STATUS_H + INPUT_H + PAD + 8))
end
relayout()

-- ---- message rendering -------------------------------------------------------
local msgOrder = 0
local currentAssistant: TextLabel? = nil

local function addMessage(role: string, roleColor: Color3, text: string): TextLabel
	msgOrder += 1
	local item = Instance.new("Frame")
	item.BackgroundTransparency = 1
	item.AutomaticSize = Enum.AutomaticSize.Y
	item.Size = UDim2.new(1, -4, 0, 0)
	item.LayoutOrder = msgOrder
	item.Parent = log
	local v = Instance.new("UIListLayout")
	v.SortOrder = Enum.SortOrder.LayoutOrder
	v.Padding = UDim.new(0, 2)
	v.Parent = item
	local r = Instance.new("TextLabel")
	r.BackgroundTransparency = 1
	r.Size = UDim2.new(1, 0, 0, 14)
	r.Font = Enum.Font.GothamBold
	r.TextSize = 11
	r.TextXAlignment = Enum.TextXAlignment.Left
	r.TextColor3 = roleColor
	r.Text = role
	r.LayoutOrder = 1
	r.Parent = item
	local b = Instance.new("TextLabel")
	b.BackgroundTransparency = 1
	b.Size = UDim2.new(1, 0, 0, 0)
	b.AutomaticSize = Enum.AutomaticSize.Y
	b.Font = Enum.Font.Gotham
	b.TextSize = 13
	b.TextWrapped = true
	b.TextXAlignment = Enum.TextXAlignment.Left
	b.TextYAlignment = Enum.TextYAlignment.Top
	b.TextColor3 = COL.text
	b.Text = text
	b.LayoutOrder = 2
	b.Parent = item
	return b
end

local function addTool(name: string)
	msgOrder += 1
	local lbl = Instance.new("TextLabel")
	lbl.BackgroundTransparency = 1
	lbl.Size = UDim2.new(1, -4, 0, 16)
	lbl.AutomaticSize = Enum.AutomaticSize.Y
	lbl.Font = Enum.Font.GothamMedium
	lbl.TextSize = 12
	lbl.TextWrapped = true
	lbl.TextXAlignment = Enum.TextXAlignment.Left
	lbl.TextColor3 = TOOLCOL
	lbl.Text = "\u{2699} " .. name
	lbl.LayoutOrder = msgOrder
	lbl.Parent = log
end

local function clearChat()
	for _, ch in log:GetChildren() do
		if ch:IsA("Frame") or ch:IsA("TextLabel") then
			ch:Destroy()
		end
	end
	currentAssistant = nil
	msgOrder = 0
end

local function ui(text: string)
	statusLabel.Text = text
	setStatus(text)
end

-- Re-apply theme colors when the user switches Studio light/dark.
local function applyTheme()
	loadColors()
	root.BackgroundColor3 = COL.bg
	title.TextColor3 = COL.text
	log.BackgroundColor3 = COL.chat
	statusLabel.TextColor3 = COL.sub
	toggleLabel.TextColor3 = COL.sub
	for _, b in { modelBtn, resetBtn, signOutBtn } do
		b.BackgroundColor3 = COL.btn
		b.TextColor3 = COL.text
	end
	for _, b in { authBtn, sendBtn } do
		b.BackgroundColor3 = COL.primary
		b.TextColor3 = COL.primaryText
	end
	inputBox.BackgroundColor3 = COL.input
	inputBox.TextColor3 = COL.text
	inputBox.PlaceholderColor3 = COL.sub
end
pcall(function()
	Studio.ThemeChanged:Connect(applyTheme)
end)

-- ===========================================================================
-- Request dispatch + poll loop
-- ===========================================================================

local function handleToolCall(requestId: string, endpoint: string, requestData: any, confirmLabel: string?)
	if confirmLabel and confirmLabel ~= "" then
		if not askConfirm(confirmLabel) then
			httpJson("POST", "/plugin/response", {
				requestId = requestId,
				response = "User declined this destructive operation; it was not performed.",
			})
			return
		end
	end
	local handler = handlers[endpoint]
	if not handler then
		httpJson("POST", "/plugin/response", { requestId = requestId, error = "unknown endpoint: " .. tostring(endpoint) })
		return
	end
	local ok, result = pcall(handler, requestData or {})
	if ok then
		httpJson("POST", "/plugin/response", { requestId = requestId, response = result })
	else
		httpJson("POST", "/plugin/response", { requestId = requestId, error = tostring(result) })
	end
end

local function handleEvent(event: string, payload: any)
	payload = payload or {}
	if event == "assistant_delta" then
		if not currentAssistant then
			currentAssistant = addMessage("Genesis", GENESIS, "")
		end
		local lbl = currentAssistant :: TextLabel
		lbl.Text = lbl.Text .. tostring(payload.text)
	elseif event == "assistant_end" then
		currentAssistant = nil
	elseif event == "tool" then
		currentAssistant = nil
		addTool(tostring(payload.name))
	elseif event == "plan" then
		currentAssistant = nil
		local tasks = payload.tasks or {}
		local mark = { pending = "\u{25CB}", in_progress = "\u{25D0}", done = "\u{2713}" }
		local lines, done = {}, 0
		for _, t in tasks do
			if t.status == "done" then done += 1 end
			table.insert(lines, (mark[t.status] or "\u{25CB}") .. " " .. tostring(t.title))
		end
		if #lines > 0 then
			addMessage(("Plan (%d/%d)"):format(done, #lines), TOOLCOL, table.concat(lines, "\n"))
		end
	elseif event == "memory_updated" then
		-- silent; status only, to avoid chat noise
		setStatus("memory updated")
	elseif event == "paused" then
		currentAssistant = nil
		ui("paused \u{00B7} send a message to resume")
	elseif event == "balance" then
		-- Server-side balance change (trial unlock, top-up, subscription, referral, refund):
		-- refresh the credit label live, without waiting for the next agent turn.
		setCredits(payload.balance)
	elseif event == "turn_done" then
		currentAssistant = nil
		setCredits(payload.balance)
		ui(("ready \u{00B7} %s credits used"):format(tostring(payload.creditsCharged or 0)))
	elseif event == "error" then
		currentAssistant = nil
		lastError = tostring(payload.message)
		addMessage("Error", ERRCOL, tostring(payload.message))
		ui("error")
	end
end

local function pollLoop(myGeneration: number)
	while connected and generation == myGeneration do
		local ok, payload = httpJson("GET", "/plugin/poll")
		if not ok then
			ui("poll error, retrying...")
			task.wait(ERROR_BACKOFF)
		elseif payload and payload.delivery then
			local d = payload.delivery
			if d.kind == "tool_call" then
				handleToolCall(d.requestId, d.endpoint, d.data, d.confirm)
			elseif d.kind == "event" then
				handleEvent(d.event, d.payload)
			end
		else
			task.wait(IDLE_POLL)
		end
	end
end

-- ===========================================================================
-- Auth (login/signup) / connect / chat
-- ===========================================================================

local function connect()
	if AUTH_TOKEN == "" then
		ui("tap Connect account to begin")
		return
	end
	if connected then
		return
	end
	ui("connecting...")
	local ok, result = httpJson("POST", "/plugin/ready", {})
	if not ok then
		ui("connect failed: " .. tostring(result))
		return
	end
	connected = true
	generation += 1
	local myGeneration = generation
	updateButton()
	authRow.Visible = false
	signOutBtn.Visible = true
	reportBtn.Visible = true
	relayout()
	needsCard = result.needsCard == true
	setCredits(result.credits)
	if result.email and result.email ~= "" then
		ui("signed in as " .. tostring(result.email))
	else
		ui("connected")
	end
	task.spawn(pollLoop, myGeneration)
end

local function disconnect()
	if not connected then
		return
	end
	connected = false
	generation += 1
	updateButton()
	authRow.Visible = true
	signOutBtn.Visible = false
	reportBtn.Visible = false
	nudge.Visible = false
	relayout()
	httpJson("POST", "/plugin/disconnect", {})
	ui("disconnected")
end

-- Sign out / switch account: forget the saved token (so it won't auto-reconnect) and drop to
-- the Connect screen, where a different account can be linked.
local function signOut()
	AUTH_TOKEN = ""
	setSetting("genesis_token", "")
	if connected then
		disconnect() -- hides signOutBtn + nudge, shows authRow, relayouts
	else
		authRow.Visible = true
		signOutBtn.Visible = false
		reportBtn.Visible = false
		nudge.Visible = false
		relayout()
	end
	lastCredits = nil
	creditsLabel.Text = ""
	ui("signed out \u{00B7} tap Connect account to log in")
end

-- Pairing: try to auto-open the browser (real plugin Scripts can on desktop Studio); always
-- show the copyable link as a fallback. The plugin polls (private 128-bit code) for the token.
local PAIR_POLL = 2 -- seconds between poll attempts
local PAIR_TIMEOUT = 600 -- give up after 10 minutes (matches the server pairing TTL)
local pairing = false

local function startPairing()
	if pairing or connected then
		return
	end
	pairing = true
	authBtn.Text = "Waiting for sign-in..."
	ui("starting sign-in...")
	task.spawn(function()
		local ok, res = httpJsonPublic("POST", "/plugin/pair/start", {})
		if not ok or type(res) ~= "table" or not res.code then
			pairing = false
			authBtn.Text = "Connect account"
			ui("could not start sign-in (check internet)")
			return
		end
		local code = res.code -- secret 128-bit poll code (private; plugin polls with it)
		-- Studio plugins can't open a browser (OpenBrowserWindow is RobloxScriptSecurity) or
		-- write the clipboard — both confirmed blocked. So we show a short sign-in link for the
		-- user to copy (Ctrl+C) and open. The short code is in the path (/g/CODE) → sign in, Connect.
		showPair(BASE_URL .. "/g/" .. tostring(res.confirm))
		ui("copy the link below into your browser & sign in to finish")
		local waited = 0
		while pairing and waited < PAIR_TIMEOUT do
			task.wait(PAIR_POLL)
			waited += PAIR_POLL
			local pok, pres = httpJsonPublic("GET", "/plugin/pair/poll?code=" .. code, nil)
			if pok and type(pres) == "table" and pres.token then
				AUTH_TOKEN = pres.token
				setSetting("genesis_token", AUTH_TOKEN)
				pairing = false
				authBtn.Text = "Connect account"
				hidePair()
				connect()
				return
			end
		end
		if pairing then
			pairing = false
			authBtn.Text = "Connect account"
			hidePair()
			ui("sign-in timed out \u{2014} tap Connect to try again")
		end
	end)
end

local function sendChat()
	local msg = inputBox.Text
	if msg:gsub("%s", "") == "" then
		return
	end
	if not connected then
		ui("connect your account first")
		return
	end
	addMessage("You", ACCENT, msg)
	inputBox.Text = ""
	ui("thinking...")
	task.spawn(function()
		local ok, res = httpJson("POST", "/plugin/chat", { message = msg, model = MODEL_CHOICE })
		if not ok then
			addMessage("Error", ERRCOL, "send failed: " .. tostring(res))
			ui("send failed")
		end
	end)
end

-- ===========================================================================
-- Wiring
-- ===========================================================================

local function refreshModelBtn()
	modelBtn.Text = MODELS[modelIndex][2]
	modelTip.Text = MODELS[modelIndex][3]
end
refreshModelBtn()
modelBtn.MouseButton1Click:Connect(function()
	modelIndex = (modelIndex % #MODELS) + 1
	MODEL_CHOICE = MODELS[modelIndex][1]
	setSetting("genesis_model", MODEL_CHOICE)
	refreshModelBtn()
end)
-- Show the description while hovering the selector.
modelBtn.MouseEnter:Connect(function()
	modelTip.Text = MODELS[modelIndex][3]
	modelTip.Visible = true
end)
modelBtn.MouseLeave:Connect(function()
	modelTip.Visible = false
end)

resetBtn.MouseButton1Click:Connect(function()
	clearChat()
	if connected then
		task.spawn(function()
			httpJson("POST", "/plugin/reset", {})
		end)
	end
end)

authBtn.MouseButton1Click:Connect(startPairing)
signOutBtn.MouseButton1Click:Connect(signOut)
pairCancel.MouseButton1Click:Connect(function()
	pairing = false
	hidePair()
	authBtn.Text = "Connect account"
	ui("cancelled \u{2014} tap Connect account to try again")
end)

sendBtn.MouseButton1Click:Connect(sendChat)
inputBox.FocusLost:Connect(function(enterPressed: boolean)
	if enterPressed then
		sendChat()
	end
end)

button.Click:Connect(function()
	widget.Enabled = not widget.Enabled
	button:SetActive(widget.Enabled)
end)

plugin.Unloading:Connect(function()
	if connected then
		disconnect()
	end
end)

-- Returning user (token saved from a previous login) → connect automatically.
if AUTH_TOKEN ~= "" then
	task.spawn(connect)
else
	ui("tap Connect account to begin")
end
]]></ProtectedString>
</Properties>
</Item>
</roblox>
