<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
	<External>null</External>
	<External>nil</External>
	<Item class="Script" referent="RBX4AFE88BD6785421C977EE938DC3594CF">
		<Properties>
			<ProtectedString name="Source"><![CDATA[--[[
    UnlimitedSurf AI Plugin for Roblox Studio  (lazy-scan edition)
    ==============================================================
    Sends a lightweight tree summary of your game on scan, then lets the AI
    pull individual scripts and properties on demand via tools.

    SETUP:
    1. Install as a local plugin (Plugins folder).
    2. First run pops a settings dialog asking for your API key.
    3. Click "🔄 Re-scan" to (re)load the tree summary.
--]]

-- ============================================================
--  CONFIG
-- ============================================================
local CONFIG = {
	BASE_URL = "https://unlimited.surf",
	MODEL    = "claude-opus-4-7",
	EFFORT   = "medium",            -- "low" | "medium" | "high"

	SCAN_SERVICES = {
		"Workspace", "ServerScriptService", "StarterPlayer", "StarterGui",
		"StarterPack", "ReplicatedStorage", "ReplicatedFirst", "ServerStorage",
		"Lighting", "SoundService", "Teams", "Chat",
	},

	MAX_TREE_CHARS   = 300000,      -- size cap on the lazy tree dump
	MAX_SCRIPT_CHARS = 200000,       -- per get_script_source response
	MAX_PROMPT_CHARS = 600000,      -- safety cap on flattened prompt
	MAX_TOOL_ROUNDS  = 12,            -- keep in sync with DEFAULT_SETTINGS.max_tool_rounds
	HTTP_TIMEOUT_SEC = 90,

	-- ── Plans storage ──────────────────────────────────────────
	-- On load the plugin ensures ServerScriptService.<PLANS_ROOT_NAME>.<PLANS_FOLDER_NAME>
	-- exists. The AI reads/writes plans there via list_plans / read_plan / write_plan.
	PLANS_ROOT_NAME   = "UnlimitedAi",  -- folder created in ServerScriptService
	PLANS_FOLDER_NAME = "Plans",         -- subfolder that holds the plan files
	-- A live copy of the system prompt is materialized here on load so the AI
	-- (and you) can reread it anytime via read_system_prompt.
	SYSTEM_PROMPT_FOLDER_NAME = "SystemPrompt",  -- sibling folder of Plans
	SYSTEM_PROMPT_SCRIPT_NAME = "Prompt",        -- ModuleScript holding the prompt

	-- ── Default test rig ────────────────────────────────────────
	-- Models with parts only RENDER (and can be visually previewed/posed) when
	-- parented somewhere under Workspace — ServerScriptService is a script
	-- container and never draws anything. So the rig lives in its own
	-- "UnlimitedAi" folder under Workspace (a sibling of, not the same as,
	-- the ServerScriptService.UnlimitedAi folder that holds Plans/SystemPrompt).
	RIG_FOLDER_NAME = "UnlimitedAi",   -- folder created in Workspace
	RIG_NAME        = "TestRig",        -- name of the auto-spawned default rig
	RIG_TYPE        = "R15",            -- "R15" | "R6"

	-- ── Shared icon library backend (Firestore) ──────────────────
	-- Public REST API, no auth needed for the calls this plugin makes:
	-- reading /icons (public read, locked write — curated by the project
	-- owner via the Firebase console) and creating /submissions (public
	-- create, no read — a one-way inbox for new icon suggestions).
	FIREBASE_PROJECT_ID = "unlimited-ai-af35f",

	-- ── AI icon describer server ─────────────────────────────────
	-- Optional companion Flask service (app.py) that takes a Roblox asset
	-- id, fetches its real thumbnail, asks a free vision model what it
	-- actually depicts, and auto-stores the result in the shared Firestore
	-- /icons collection. Set this to your deployed server's base URL (no
	-- trailing slash), e.g. "https://your-app.onrender.com". Leave empty to
	-- disable the describe_icon tool. Can also be set at runtime via the
	-- "describer_url" plugin setting.
	DESCRIBER_URL = "https://private-project-4hzq.onrender.com",
}
-- applySettingsToConfig() is defined further down, AFTER the settings helpers
-- (loadSettings / getSetting) exist. It used to live here, above those helpers,
-- which meant it captured them as nil GLOBALS and bailed out on the first line
-- without applying anything — so saved timeout / effort / budgets were silently
-- ignored and the hardcoded CONFIG defaults were always used.
-- ============================================================

local HttpService          = game:GetService("HttpService")
local Selection            = game:GetService("Selection")
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local UserInputService     = game:GetService("UserInputService")
local ServerScriptService  = game:GetService("ServerScriptService")

-- ── Persistent settings ──────────────────────────────────────
local function getApiKey() return plugin:GetSetting("unlimited_api_key") end
local function setApiKey(k) plugin:SetSetting("unlimited_api_key", k) end
-- Standing goals: a user-set checklist the AI keeps in mind every turn.
-- Stored as a JSON array of { text = "...", done = bool } under one setting.
local function getGoals()
	local raw = plugin:GetSetting("unlimited_goals")
	if type(raw) == "string" and raw ~= "" then
		local ok, decoded = pcall(function() return HttpService:JSONDecode(raw) end)
		if ok and type(decoded) == "table" then return decoded end
	end
	-- Back-compat: migrate an old single-string goal if present
	local legacy = plugin:GetSetting("unlimited_goal")
	if type(legacy) == "string" and legacy ~= "" then
		return { { text = legacy, done = false } }
	end
	return {}
end
local function saveGoals(list)
	plugin:SetSetting("unlimited_goals", HttpService:JSONEncode(list))
	plugin:SetSetting("unlimited_goal", nil)  -- retire legacy key
end
-- ── Project notes (durable cross-session memory) ──────────────
-- Free-form text the AI reads every session: architecture, conventions,
-- decisions. Stored as one plain-string setting.
local function getProjectNotes()
	local raw = plugin:GetSetting("unlimited_project_notes")
	if type(raw) == "string" then return raw end
	return ""
end
local function saveProjectNotes(text)
	plugin:SetSetting("unlimited_project_notes", tostring(text or ""))
end
-- ── Icon library (curated, pre-vetted Roblox asset icons) ─────
-- Stored as a JSON array under one plugin setting: { {id=assetId, name=..,
-- description=.., category=.., source="catalog"|"manual"}, ... }. Plugin
-- settings persist per-install (not per-place), so this library follows you
-- across every project — seed it once, reuse everywhere. Seeded the first
-- time it's touched with a starter set of icons the user already vetted by
-- hand (close/confirm/coin/rebirth/luck/leaderboard/gift/shop), since the
-- catalog item-details API mainly covers avatar wearables, not plain
-- Decal/Image assets like these — for those, a human-checked description is
-- more reliable than an automated guess.
local DEFAULT_ICON_LIBRARY = {
	{ id = 133565026221740, name = "Building",        description = "Shop building icon", category = "shop", source = "manual" },
	{ id = 14736132203,     name = "Shopping_Basket",  description = "Shop image — shopping basket", category = "shop", source = "manual" },
	{ id = 80349462371432,  name = "Shopping_Basket2", description = "Shop icon — shopping basket", category = "shop", source = "manual" },
	{ id = 14721361339,     name = "Close",            description = "Red X close button", category = "close", source = "manual" },
	{ id = 127421931119714, name = "cancel",           description = "Red X with black border, close/cancel button", category = "close", source = "manual" },
	{ id = 123961663770124, name = "confirm",          description = "Green checkmark with black border, confirm button (same creator/style as the red X black border)", category = "confirm", source = "manual" },
	{ id = 102307280014335, name = "rebirth",          description = "Rebirth icon, clockwise arrows, top arrow pink / bottom arrow white", category = "rebirth", source = "manual" },
	{ id = 111496417412648, name = "rebirth",          description = "Rebirth icon, clockwise arrows, top arrow yellow / bottom arrow white", category = "rebirth", source = "manual" },
	{ id = 88349123294726,  name = "Rebirth",          description = "Rebirth icon, more 2D style, red top arrow / white bottom arrow", category = "rebirth", source = "manual" },
	{ id = 138877808417139, name = "coin",             description = "Yellow coin, black border, 3D style", category = "coin", source = "manual" },
	{ id = 75215877767190,  name = "coin",             description = "Yellow coin, black border, 2D style", category = "coin", source = "manual" },
	{ id = 91344841658831,  name = "coin-pile",        description = "Pile of coins, same 2D style as the 2D coin icon", category = "coin", source = "manual" },
	{ id = 89118380394851,  name = "coin-chest",       description = "Chest full of coins, same 2D style as the coin/coin-pile icons", category = "coin", source = "manual" },
	{ id = 129360611840213, name = "luck",             description = "Green four-leaf clover with black border", category = "luck", source = "manual" },
	{ id = 130979248444073, name = "leaderboard",      description = "Yellow leaderboard icon", category = "leaderboard", source = "manual" },
	{ id = 139988932455302, name = "gift",             description = "Red gift box with a yellow ribbon/bow on top", category = "gift", source = "manual" },
	-- UI background textures — tile these (ScaleType=Tile), don't stretch. The
	-- studs one is transparent, so a BackgroundColor3/UIGradient behind it tints it.
	{ id = 75879630349864,  name = "studs",            description = "Transparent tiling stud grid — the classic Roblox UI panel background", category = "texture", source = "manual" },
	{ id = 7971901039,      name = "carbon-fiber",     description = "Dark diagonal carbon-fiber weave, tiling background for sleek/tech panels", category = "texture", source = "manual" },
	{ id = 6062528791,      name = "hexagon",          description = "Tiling hexagon/honeycomb lattice background (store-named 'grid'; not related to UIGridLayout)", category = "texture", source = "manual" },
}
local function getIconLibrary()
	local raw = plugin:GetSetting("unlimited_icon_library")
	if type(raw) == "string" and raw ~= "" then
		local ok, decoded = pcall(function() return HttpService:JSONDecode(raw) end)
		if ok and type(decoded) == "table" then return decoded end
	end
	return nil -- never initialized; caller seeds DEFAULT_ICON_LIBRARY
end
local function saveIconLibrary(list)
	plugin:SetSetting("unlimited_icon_library", HttpService:JSONEncode(list))
end
local function ensureIconLibrary()
	local lib = getIconLibrary()
	if lib == nil then
		lib = {}
		for _, e in ipairs(DEFAULT_ICON_LIBRARY) do table.insert(lib, e) end
		saveIconLibrary(lib)
	end
	return lib
end

-- ── Firestore REST helpers (shared icon library backend) ──────
-- Firestore's REST API wants every field wrapped in a {typeName = value}
-- envelope. These convert plain Lua tables <-> that shape so the rest of the
-- code can just work with normal tables.
local FIRESTORE_BASE = "https://firestore.googleapis.com/v1/projects/" ..
	CONFIG.FIREBASE_PROJECT_ID .. "/databases/(default)/documents"

local function toFirestoreValue(v)
	local t = type(v)
	if t == "string" then
		return { stringValue = v }
	elseif t == "boolean" then
		return { booleanValue = v }
	elseif t == "number" then
		if v == math.floor(v) then
			return { integerValue = tostring(math.floor(v)) }
		else
			return { doubleValue = v }
		end
	elseif t == "table" then
		local isArray = (#v > 0) or (next(v) == nil)
		if isArray then
			local values = {}
			for _, item in ipairs(v) do table.insert(values, toFirestoreValue(item)) end
			return { arrayValue = { values = values } }
		else
			local fields = {}
			for k, val in pairs(v) do fields[k] = toFirestoreValue(val) end
			return { mapValue = { fields = fields } }
		end
	end
	return { nullValue = "NULL_VALUE" }
end

local function toFirestoreFields(tbl)
	local fields = {}
	for k, v in pairs(tbl) do fields[k] = toFirestoreValue(v) end
	return fields
end

local function fromFirestoreFields(fields) -- forward-declared for mutual recursion
end

local function fromFirestoreValue(v)
	if v == nil then return nil end
	if v.stringValue ~= nil then return v.stringValue end
	if v.integerValue ~= nil then return tonumber(v.integerValue) end
	if v.doubleValue ~= nil then return v.doubleValue end
	if v.booleanValue ~= nil then return v.booleanValue end
	if v.mapValue ~= nil then return fromFirestoreFields(v.mapValue.fields or {}) end
	if v.arrayValue ~= nil then
		local out = {}
		for _, item in ipairs(v.arrayValue.values or {}) do table.insert(out, fromFirestoreValue(item)) end
		return out
	end
	return nil
end

fromFirestoreFields = function(fields)
	local out = {}
	for k, v in pairs(fields or {}) do out[k] = fromFirestoreValue(v) end
	return out
end

-- ── Script snapshots (code safety net beyond undo) ────────────
-- Stored as JSON: { [name] = { time=..., scripts = { [path]=source, ... } } }.
-- Capped in total size so plugin settings don't blow up.
local SNAPSHOT_MAX_BYTES = 600000
local function getSnapshots()
	local raw = plugin:GetSetting("unlimited_snapshots")
	if type(raw) == "string" and raw ~= "" then
		local ok, decoded = pcall(function() return HttpService:JSONDecode(raw) end)
		if ok and type(decoded) == "table" then return decoded end
	end
	return {}
end
local function saveSnapshots(tbl)
	plugin:SetSetting("unlimited_snapshots", HttpService:JSONEncode(tbl))
end
-- ── Conversation history persistence (survives /rescan + reloads) ──
local function saveHistoryBlob(history)
	local ok, encoded = pcall(function() return HttpService:JSONEncode(history) end)
	if ok and #encoded <= 800000 then
		plugin:SetSetting("unlimited_history", encoded)
	end
end
local function loadHistoryBlob()
	local raw = plugin:GetSetting("unlimited_history")
	if type(raw) == "string" and raw ~= "" then
		local ok, decoded = pcall(function() return HttpService:JSONDecode(raw) end)
		if ok and type(decoded) == "table" then return decoded end
	end
	return nil
end
-- ── Settings storage ────────────────────────────────────────
-- All non-key settings live in a single JSON blob under one setting name.
-- This makes migration easier — adding/removing fields doesn't pollute the
-- plugin's setting namespace.

local SETTINGS_KEY = "unlimited_settings"

local DEFAULT_SETTINGS = {
	base_url         = "https://unlimited.surf",

	-- ── Provider settings ─────────────────────────────
	-- Provider display order (DeepCode is the default and listed first):
	--   1. DeepCode
	--   2. Unlimited Surf AI
	--   3. Homelander
	--   4. GPT 5.5
	homelander_base_url = "https://your-server", -- placeholder until real Homelander URL is back online
	homelander_api_key  = "homelander",
	gpt_base_url        = "https://theproxy-production-e112.up.railway.app/v1",
	gpt_api_key         = "admin",
	deepcode_base_url   = "https://use-ai-production.up.railway.app/v1",
	deepcode_api_key    = "", -- DeepCode says empty / any key works
	http_timeout_sec = 90,
	effort           = "medium",     -- "low" | "medium" | "high"
	max_tool_rounds  = 12,
	max_prompt_chars = 600000,
	max_script_chars = 200000,
	max_tree_chars   = 300000,
	debug_prints     = false,
	-- Permanent bypass: when true the high-risk action gate is skipped on
	-- every plugin load until turned back off in the Settings dialog. Persisted.
	bypass_all       = false,
	-- When true, the plugin will NOT auto-create its helper objects on load
	-- (the ServerScriptService.UnlimitedAi Plans/SystemPrompt folders and the
	-- Workspace test rig). Features that depend on those objects (plan storage,
	-- read_system_prompt, the default test rig) stop working until this is
	-- turned back off. Persisted.
	disable_helper_objects = false,
}

-- ── Runtime bypass flag (NOT persisted) ─────────────────────
-- True = high-risk action gate is skipped silently. Default false; hydrated
-- from the persisted "bypass_all" setting right after settings load (below),
-- and toggled live from the Settings dialog.
local bypassAll = false

local function isBypassActive() return bypassAll end

local function setBypass(enabled)
	bypassAll = enabled and true or false
	return bypassAll
end

local cachedSettings = nil

local function loadSettings()
	if cachedSettings then return cachedSettings end
	local raw = plugin:GetSetting(SETTINGS_KEY)
	cachedSettings = {}
	-- Start with defaults
	for k, v in pairs(DEFAULT_SETTINGS) do
		cachedSettings[k] = v
	end
	-- Overlay saved values, ignoring unknown keys
	if type(raw) == "string" and raw ~= "" then
		local ok, decoded = pcall(HttpService.JSONDecode, HttpService, raw)
		if ok and type(decoded) == "table" then
			for k, _ in pairs(DEFAULT_SETTINGS) do
				if decoded[k] ~= nil then
					cachedSettings[k] = decoded[k]
				end
			end
		end
	end
	return cachedSettings
end

local function saveSettings(updates)
	local current = loadSettings()
	for k, v in pairs(updates) do
		if DEFAULT_SETTINGS[k] ~= nil then  -- only persist known keys
			current[k] = v
		end
	end
	plugin:SetSetting(SETTINGS_KEY, HttpService:JSONEncode(current))
	cachedSettings = current
	return current
end

local function getSetting(name)
	return loadSettings()[name]
end

local function resetSettings()
	cachedSettings = nil
	plugin:SetSetting(SETTINGS_KEY, nil)
	return loadSettings()
end
-- Now that the settings helpers exist, overlay saved values onto CONFIG.
-- Defined HERE (not earlier) so loadSettings/getSetting resolve as real upvalues.
local function applySettingsToConfig()
	CONFIG.BASE_URL         = getSetting("base_url")         or CONFIG.BASE_URL
	CONFIG.EFFORT           = getSetting("effort")           or CONFIG.EFFORT
	CONFIG.HTTP_TIMEOUT_SEC = getSetting("http_timeout_sec") or CONFIG.HTTP_TIMEOUT_SEC
	CONFIG.MAX_TOOL_ROUNDS  = getSetting("max_tool_rounds")  or CONFIG.MAX_TOOL_ROUNDS
	CONFIG.MAX_PROMPT_CHARS = getSetting("max_prompt_chars") or CONFIG.MAX_PROMPT_CHARS
	CONFIG.MAX_SCRIPT_CHARS = getSetting("max_script_chars") or CONFIG.MAX_SCRIPT_CHARS
	CONFIG.MAX_TREE_CHARS   = getSetting("max_tree_chars")   or CONFIG.MAX_TREE_CHARS
	CONFIG.DESCRIBER_URL    = getSetting("describer_url")    or CONFIG.DESCRIBER_URL
end
applySettingsToConfig()
-- Hydrate the persistent bypass flag from saved settings.
setBypass(getSetting("bypass_all"))

-- Convenience wrapper for the debug-prints toggle
local function debugPrint(...)
	if getSetting("debug_prints") then
		print("[UnlimitedAI:debug]", ...)
	end
end
-- ── Provider + model registry ────────────────────────────────
-- Provider display order (DeepCode is the default and listed first):
--   1. DeepCode
--   2. Unlimited Surf AI
--   3. Homelander
--   4. GPT 5.5
local PROVIDER_ORDER = { "deepcode", "unlimited", "homelander", "gpt55" }

local PROVIDERS = {
	unlimited = {
		id = "unlimited",
		display = "Unlimited Surf AI",
		api_type = "unlimited",
		base_setting = "base_url",
		default_model_id = "claude-opus-4-7-20260101",
		models = {
			{ id = "claude-opus-4-8-20260501", display = "Claude Opus 4.8" },
			{ id = "claude-opus-4-7-20260101", display = "Claude Opus 4.7" },
			{ id = "claude-opus-4-6-20251201", display = "Claude Opus 4.6" },
			{ id = "claude-opus-4-5-20251101", display = "Claude Opus 4.5" },
			{ id = "claude-opus-4-1-20250805", display = "Claude Opus 4.1" },
			{ id = "claude-sonnet-4-6-20260101", display = "Claude Sonnet 4.6" },
			{ id = "claude-sonnet-4-20250514",   display = "Claude Sonnet 4"   },
		},
	},

	homelander = {
		id = "homelander",
		display = "Homelander",
		api_type = "anthropic",
		base_setting = "homelander_base_url",
		key_setting = "homelander_api_key",
		default_model_id = "claude-opus-4-8",
		models = {
			{ id = "claude-opus-4-8",   display = "Opus 4.8" },
			{ id = "claude-sonnet-4-6", display = "Sonnet 4.6" },
			{ id = "claude-haiku-4-5",  display = "Haiku 4.5" },
		},
	},

	gpt55 = {
		id = "gpt55",
		display = "GPT Chat",
		api_type = "openai",
		base_setting = "gpt_base_url",
		key_setting = "gpt_api_key",
		default_model_id = "gpt-5.5",
		models = {
			{ id = "gpt-5.5", display = "GPT 5.5" },
		},
	},

	deepcode = {
		id = "deepcode",
		display = "DeepCode",
		api_type = "openai",
		base_setting = "deepcode_base_url",
		key_setting = "deepcode_api_key",
		allow_empty_key = true,
		default_model_id = "gpt-5-4",
		models = {
			{ id = "gpt-5-5", display = "OpenAI GPT-5.5" },
			{ id = "gpt-5-4", display = "OpenAI GPT-5.4" },
			{ id = "gpt-5-3", display = "OpenAI GPT-5.3" },
			{ id = "gpt-5-1", display = "OpenAI GPT-5.1" },
			{ id = "gpt-5", display = "OpenAI GPT-5" },
			{ id = "gpt-5-mini", display = "OpenAI GPT-5 Mini" },
			{ id = "gpt-4o", display = "OpenAI GPT-4o" },
			{ id = "gpt-4o-mini", display = "OpenAI GPT-4o Mini" },

			{ id = "claude-opus-4-8", display = "Claude Opus 4.8" },
			{ id = "claude-opus-4-7", display = "Claude Opus 4.7" },
			{ id = "claude-opus-4-6", display = "Claude Opus 4.6" },
			{ id = "claude-opus-4-5", display = "Claude Opus 4.5" },
			{ id = "claude-opus-4-1", display = "Claude Opus 4.1" },
			{ id = "claude-sonnet-4-6", display = "Claude Sonnet 4.6" },

			{ id = "gemini-3-1-pro", display = "Gemini 3.1 Pro" },
			{ id = "gemini-3-pro", display = "Gemini 3 Pro" },
			{ id = "gemini-3-flash", display = "Gemini 3 Flash" },
			{ id = "gemini-2.5-flash", display = "Gemini 2.5 Flash" },

			{ id = "deepseek-v4-pro", display = "DeepSeek V4 Pro" },
			{ id = "deepseek-v4-flash", display = "DeepSeek V4 Flash" },
			{ id = "deepseek-r1", display = "DeepSeek R1" },

			{ id = "grok-4", display = "Grok 4" },
			{ id = "qwen-3-max", display = "Qwen 3 Max" },
			{ id = "qwen-3-5-397b", display = "Qwen 3.5" },
			{ id = "kimi-k2-6", display = "Kimi K2.6" },
			{ id = "deepinfra-kimi-k2", display = "Kimi K2" },
			{ id = "llama-3-3-70b-versatile", display = "Llama 3.3" },
		},
	},
}

local providerStatus = {
	unlimited = true,
	homelander = true,
	gpt55 = true,
	deepcode = true,
}

local MODELS = {}
do
	for _, providerId in ipairs(PROVIDER_ORDER) do
		local p = PROVIDERS[providerId]
		for _, m in ipairs(p.models) do
			local copy = {}
			for k, v in pairs(m) do copy[k] = v end
			copy.provider = providerId
			copy.providerDisplay = p.display
			table.insert(MODELS, copy)
		end
	end
end

local function setProviderStatus(providerId, online)
	providerStatus[providerId] = online and true or false
end

local function getProviderStatusText(providerId)
	return providerStatus[providerId] and "Online" or "Offline"
end

local function getProviderStatusColor(providerId)
	return providerStatus[providerId]
		and Color3.fromRGB(100, 220, 130)
		or Color3.fromRGB(230, 110, 110)
end

-- Strip "-YYYYMMDD" suffix → short id the Unlimited /api/chat endpoint accepts
local function shortModelId(fullId)
	return (fullId:gsub("%-%d%d%d%d%d%d%d%d$", ""))
end

local function getProviderDefaultModel(providerId)
	local p = PROVIDERS[providerId]
	if not p then return MODELS[1] end
	for _, m in ipairs(MODELS) do
		if m.provider == providerId and m.id == p.default_model_id then
			return m
		end
	end
	for _, m in ipairs(MODELS) do
		if m.provider == providerId then return m end
	end
	return MODELS[1]
end

local function getCurrentModel()
	local savedProvider = plugin:GetSetting("unlimited_model_provider")
	local savedModelId = plugin:GetSetting("unlimited_model_id")

	if savedModelId then
		for _, m in ipairs(MODELS) do
			if m.id == savedModelId and (not savedProvider or m.provider == savedProvider) then
				return m
			end
		end
	end

	return getProviderDefaultModel("deepcode")
end

local function getSelectedModelForProvider(providerId)
	local current = getCurrentModel()
	if current and current.provider == providerId then
		return current
	end
	return getProviderDefaultModel(providerId)
end

local function setCurrentModel(modelId, providerId)
	for _, m in ipairs(MODELS) do
		if m.id == modelId and (not providerId or m.provider == providerId) then
			plugin:SetSetting("unlimited_model_provider", m.provider)
			plugin:SetSetting("unlimited_model_id", m.id)
			return
		end
	end
	plugin:SetSetting("unlimited_model_provider", "deepcode")
	plugin:SetSetting("unlimited_model_id", PROVIDERS.deepcode.default_model_id)
end

local function trimTrailingSlash(s)
	s = tostring(s or "")
	return (s:gsub("/+$", ""))
end

local function joinApiPath(baseUrl, path)
	baseUrl = trimTrailingSlash(baseUrl)
	if path:sub(1, 1) ~= "/" then path = "/" .. path end
	return baseUrl .. path
end

local function getProviderBaseUrl(providerId)
	local p = PROVIDERS[providerId]
	if not p then return CONFIG.BASE_URL end
	if providerId == "unlimited" then return CONFIG.BASE_URL end
	return getSetting(p.base_setting)
end

local function getProviderApiKey(providerId)
	if providerId == "unlimited" then return getApiKey() end
	local p = PROVIDERS[providerId]
	return p and getSetting(p.key_setting) or nil
end

local function isProviderUsable(providerId)
	local p = PROVIDERS[providerId]
	if not p then return false, "Unknown provider" end

	local baseUrl = getProviderBaseUrl(providerId)
	if not baseUrl or baseUrl == "" then
		return false, "No base URL set for " .. p.display
	end

	local key = getProviderApiKey(providerId)
	if key == nil then key = "" end

	if key == "" and not p.allow_empty_key then
		return false, "No API key set for " .. p.display
	end

	return true, nil
end

local function isCurrentProviderUsable()
	local m = getCurrentModel()
	if not m then return false, "No model selected" end
	return isProviderUsable(m.provider)
end

-- ── High-risk action gate (inline confirmation) ─────────────
local HIGH_RISK_TOOLS = {
	run_script         = true,
	set_script_source  = true,
	patch_script       = true,
	create_script      = true,
	delete_instance    = true,
	insert_model       = true,
	replace_in_scripts = true,
	create_remote      = true,
	revert_script      = true,
	delete_plan        = true,
	create_ui_animation_script = true,
	scaffold_leaderstats = true,
	scaffold_npc_behavior = true,
}

-- Path the AI is told represents "the plugin itself". Update if you rename the file.
local SELF_PATH = "ServerScriptService.UnlimitedSurfAI"

-- Tools that get a Dry-run button. (run_script and insert_model dry-runs aren't meaningful.)
local DRYRUN_BUTTON_TOOLS = {
	set_script_source = true,
	patch_script      = true,
	create_script     = true,
	delete_instance   = true,
	replace_in_scripts = true,
	create_remote      = true,
	revert_script      = true,
	create_ui_animation_script = true,
	scaffold_leaderstats = true,
	scaffold_npc_behavior = true,
	["set_property:Source"]   = true,
	["set_property:Disabled"] = true,
}

-- Per-session allowlist: key = "toolName|target" → true
local sessionAllow = {}

local function gateKey(name, args)
	local target = (args and (args.path or args.parent)) or "?"
	return name .. "|" .. tostring(target)
end

-- Forward declarations — these are assigned later in the UI section,
-- but referenced by confirmAction and the model dropdown click handler.
local scroll          -- ScrollingFrame, assigned in UI setup
local scrollToBottom  -- function, assigned in UI setup
local bubbleOrder = 0 -- counter, assigned/used by confirmAction and addBubble
local messageHistory = {} -- chat history, used by dropdown handler
-- Files the user attached via the 📎 button, pending inclusion with their
-- next message. Each entry: { name, text, parser, truncated, size }.
local pendingAttachments = {}
local addBubble       -- function, used by dropdown handler
local refreshTitle    -- function, used by settings save handler
local doScan          -- function, assigned in UI setup; used by the settings Reload button
local refreshBypassBanner  -- function, used by settings save handler

local function confirmAction(name, args)
	local key = gateKey(name, args)
	if sessionAllow[key] then
		-- Compact chat trail so auto-approved actions are still visible
		local target = (args and (args.path or args.parent)) or "?"
		bubbleOrder = bubbleOrder + 1
		local note = Instance.new("Frame")
		note.Size = UDim2.new(1, 0, 0, 0)
		note.AutomaticSize = Enum.AutomaticSize.Y
		note.BackgroundColor3 = Color3.fromRGB(40, 55, 40)
		note.BorderSizePixel = 0
		note.LayoutOrder = bubbleOrder
		note.Parent = scroll
		Instance.new("UICorner", note).CornerRadius = UDim.new(0, 6)

		local pad = Instance.new("UIPadding")
		pad.PaddingTop = UDim.new(0, 4); pad.PaddingBottom = UDim.new(0, 4)
		pad.PaddingLeft = UDim.new(0, 10); pad.PaddingRight = UDim.new(0, 10)
		pad.Parent = note

		local label = Instance.new("TextLabel")
		label.Size = UDim2.new(1, 0, 0, 18)
		label.BackgroundTransparency = 1
		label.Text = "✓ " .. name .. " · " .. tostring(target) .. " (auto-approved)"
		label.TextColor3 = Color3.fromRGB(140, 200, 140)
		label.TextSize = 11
		label.Font = Enum.Font.Gotham
		label.TextXAlignment = Enum.TextXAlignment.Left
		label.Parent = note

		scrollToBottom()
		return "approve"
	end

	args = args or {}
	local target = args.path or args.parent or "(none)"

	-- Build args preview (JSON, truncated)
	local previewOk, previewJson = pcall(function() return HttpService:JSONEncode(args) end)
	local preview = previewOk and previewJson or "(unprintable args)"
	if #preview > 600 then preview = preview:sub(1, 600) .. "... (truncated)" end

	local isSelfMod = (target == SELF_PATH) or
		(name == "create_script" and args.parent == "ServerScriptService"
			and args.name and args.name:lower():find("unlimited"))

	-- Outer bubble (reuses the same look as addBubble's "tool" kind)
	bubbleOrder = bubbleOrder + 1
	local bubble = Instance.new("Frame")
	bubble.Size = UDim2.new(1, 0, 0, 0)
	bubble.AutomaticSize = Enum.AutomaticSize.Y
	bubble.BackgroundColor3 = isSelfMod
		and Color3.fromRGB(110, 30, 30)
		or  Color3.fromRGB(70, 55, 25)
	bubble.BorderSizePixel = 0
	bubble.LayoutOrder = bubbleOrder
	bubble.Parent = scroll
	Instance.new("UICorner", bubble).CornerRadius = UDim.new(0, 8)

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, 10); pad.PaddingBottom = UDim.new(0, 10)
	pad.PaddingLeft = UDim.new(0, 12); pad.PaddingRight = UDim.new(0, 12)
	pad.Parent = bubble

	local layout = Instance.new("UIListLayout")
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Padding = UDim.new(0, 6)
	layout.Parent = bubble

	-- Heading
	local heading = Instance.new("TextLabel")
	heading.Size = UDim2.new(1, 0, 0, 20)
	heading.BackgroundTransparency = 1
	heading.Text = isSelfMod
		and "⚠️  THE AI WANTS TO MODIFY ITS OWN PLUGIN"
		or  "⚠️  Confirm action"
	heading.TextColor3 = Color3.fromRGB(255, 220, 220)
	heading.Font = Enum.Font.GothamBold
	heading.TextSize = 13
	heading.TextXAlignment = Enum.TextXAlignment.Left
	heading.LayoutOrder = 1
	heading.Parent = bubble

	-- Tool + target line
	local detail = Instance.new("TextLabel")
	detail.Size = UDim2.new(1, 0, 0, 32)
	detail.BackgroundTransparency = 1
	detail.Text = "Tool: " .. name .. "\nTarget: " .. tostring(target)
	detail.TextColor3 = Color3.fromRGB(230, 230, 245)
	detail.Font = Enum.Font.RobotoMono
	detail.TextSize = 12
	detail.TextXAlignment = Enum.TextXAlignment.Left
	detail.TextYAlignment = Enum.TextYAlignment.Top
	detail.LayoutOrder = 2
	detail.Parent = bubble

	-- Args preview box
	local previewBox = Instance.new("TextLabel")
	previewBox.Size = UDim2.new(1, 0, 0, 100)
	previewBox.AutomaticSize = Enum.AutomaticSize.Y
	previewBox.BackgroundColor3 = Color3.fromRGB(20, 16, 16)
	previewBox.BorderSizePixel = 0
	previewBox.Text = preview
	previewBox.TextColor3 = Color3.fromRGB(200, 200, 210)
	previewBox.Font = Enum.Font.RobotoMono
	previewBox.TextSize = 11
	previewBox.TextXAlignment = Enum.TextXAlignment.Left
	previewBox.TextYAlignment = Enum.TextYAlignment.Top
	previewBox.TextWrapped = true
	previewBox.LayoutOrder = 3
	previewBox.Parent = bubble
	Instance.new("UICorner", previewBox).CornerRadius = UDim.new(0, 6)
	local previewPad = Instance.new("UIPadding")
	previewPad.PaddingTop = UDim.new(0, 6); previewPad.PaddingBottom = UDim.new(0, 6)
	previewPad.PaddingLeft = UDim.new(0, 8); previewPad.PaddingRight = UDim.new(0, 8)
	previewPad.Parent = previewBox

	-- "Allow for session" toggle
	local remember = false
	local allowToggle = Instance.new("TextButton")
	allowToggle.Size = UDim2.new(1, 0, 0, 22)
	allowToggle.BackgroundTransparency = 1
	allowToggle.Text = "☐  Allow this tool + target for the rest of this session"
	allowToggle.TextColor3 = Color3.fromRGB(180, 190, 210)
	allowToggle.Font = Enum.Font.Gotham
	allowToggle.TextSize = 12
	allowToggle.TextXAlignment = Enum.TextXAlignment.Left
	allowToggle.AutoButtonColor = false
	allowToggle.LayoutOrder = 4
	allowToggle.Parent = bubble
	allowToggle.MouseButton1Click:Connect(function()
		remember = not remember
		allowToggle.Text = (remember and "☑" or "☐") ..
			"  Allow this tool + target for the rest of this session"
	end)

	-- Button row
	local btnRow = Instance.new("Frame")
	btnRow.Size = UDim2.new(1, 0, 0, 32)
	btnRow.BackgroundTransparency = 1
	btnRow.LayoutOrder = 5
	btnRow.Parent = bubble
	local rowLayout = Instance.new("UIListLayout")
	rowLayout.FillDirection = Enum.FillDirection.Horizontal
	rowLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
	rowLayout.Padding = UDim.new(0, 8)
	rowLayout.Parent = btnRow

	local decision = nil
	local function makeBtn(label, color, order, value)
		local b = Instance.new("TextButton")
		b.Size = UDim2.new(0, 90, 1, 0)
		b.BackgroundColor3 = color
		b.BorderSizePixel = 0
		b.Text = label
		b.TextColor3 = Color3.fromRGB(255, 255, 255)
		b.Font = Enum.Font.GothamMedium
		b.TextSize = 12
		b.LayoutOrder = order
		b.Parent = btnRow
		Instance.new("UICorner", b).CornerRadius = UDim.new(0, 6)
		b.MouseButton1Click:Connect(function()
			if decision then return end
			decision = value
		end)
		return b
	end

	makeBtn("Deny",     Color3.fromRGB(140, 50, 50),  1, "deny")
	if DRYRUN_BUTTON_TOOLS[name] then
		makeBtn("Dry-run",  Color3.fromRGB(100, 90, 50),  2, "dryrun")
	end
	makeBtn("Approve",  Color3.fromRGB(50, 130, 70),  3, "approve")

	scrollToBottom()

	-- Yield until user picks, or bubble destroyed (treated as deny)
	while decision == nil do
		if not bubble.Parent then
			decision = "deny"
			break
		end
		task.wait(0.05)
	end

	-- Lock the bubble in place as a record of what was decided
	for _, child in ipairs(btnRow:GetChildren()) do
		if child:IsA("TextButton") then
			child.AutoButtonColor = false
			child.Active = false
		end
	end
	allowToggle.Active = false
	heading.Text = heading.Text .. "  →  " .. decision:upper()

	if decision == "approve" and remember then
		sessionAllow[key] = true
	end
	return decision
end

-- Shows ONE confirmation bubble summarizing a multi-step plan (used by the
-- present_plan tool). Approving it pre-fills sessionAllow for every step's
-- {tool, target} pair, so those specific high-risk calls won't prompt
-- individually later in the same session — same mechanism as the per-action
-- "Allow this tool + target for the rest of this session" checkbox, just
-- applied to a whole batch at once instead of one at a time.
local function confirmPlan(steps)
	bubbleOrder = bubbleOrder + 1
	local bubble = Instance.new("Frame")
	bubble.Size = UDim2.new(1, 0, 0, 0)
	bubble.AutomaticSize = Enum.AutomaticSize.Y
	bubble.BackgroundColor3 = Color3.fromRGB(35, 45, 70)
	bubble.BorderSizePixel = 0
	bubble.LayoutOrder = bubbleOrder
	bubble.Parent = scroll
	Instance.new("UICorner", bubble).CornerRadius = UDim.new(0, 8)

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, 10); pad.PaddingBottom = UDim.new(0, 10)
	pad.PaddingLeft = UDim.new(0, 12); pad.PaddingRight = UDim.new(0, 12)
	pad.Parent = bubble

	local layout = Instance.new("UIListLayout")
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Padding = UDim.new(0, 6)
	layout.Parent = bubble

	local heading = Instance.new("TextLabel")
	heading.Size = UDim2.new(1, 0, 0, 20)
	heading.BackgroundTransparency = 1
	heading.Text = "📋  AI is proposing a " .. #steps .. "-step plan"
	heading.TextColor3 = Color3.fromRGB(210, 225, 255)
	heading.Font = Enum.Font.GothamBold
	heading.TextSize = 13
	heading.TextXAlignment = Enum.TextXAlignment.Left
	heading.LayoutOrder = 1
	heading.Parent = bubble

	local stepLines = {}
	for i, st in ipairs(steps) do
		table.insert(stepLines, string.format("%d. [%s] %s%s", i, tostring(st.tool),
			st.description or "(no description)",
			(type(st.target) == "string" and st.target ~= "") and ("  →  " .. st.target) or ""))
	end

	local stepBox = Instance.new("TextLabel")
	stepBox.Size = UDim2.new(1, 0, 0, 100)
	stepBox.AutomaticSize = Enum.AutomaticSize.Y
	stepBox.BackgroundColor3 = Color3.fromRGB(20, 24, 34)
	stepBox.BorderSizePixel = 0
	stepBox.Text = table.concat(stepLines, "\n")
	stepBox.TextColor3 = Color3.fromRGB(210, 215, 225)
	stepBox.Font = Enum.Font.RobotoMono
	stepBox.TextSize = 11
	stepBox.TextXAlignment = Enum.TextXAlignment.Left
	stepBox.TextYAlignment = Enum.TextYAlignment.Top
	stepBox.TextWrapped = true
	stepBox.LayoutOrder = 2
	stepBox.Parent = bubble
	Instance.new("UICorner", stepBox).CornerRadius = UDim.new(0, 6)
	local stepPad = Instance.new("UIPadding")
	stepPad.PaddingTop = UDim.new(0, 6); stepPad.PaddingBottom = UDim.new(0, 6)
	stepPad.PaddingLeft = UDim.new(0, 8); stepPad.PaddingRight = UDim.new(0, 8)
	stepPad.Parent = stepBox

	local note = Instance.new("TextLabel")
	note.Size = UDim2.new(1, 0, 0, 16)
	note.BackgroundTransparency = 1
	note.Text = "Approving lets every matching high-risk step below run without asking individually."
	note.TextColor3 = Color3.fromRGB(160, 175, 200)
	note.Font = Enum.Font.Gotham
	note.TextSize = 11
	note.TextXAlignment = Enum.TextXAlignment.Left
	note.LayoutOrder = 3
	note.Parent = bubble

	local btnRow = Instance.new("Frame")
	btnRow.Size = UDim2.new(1, 0, 0, 32)
	btnRow.BackgroundTransparency = 1
	btnRow.LayoutOrder = 4
	btnRow.Parent = bubble
	local rowLayout = Instance.new("UIListLayout")
	rowLayout.FillDirection = Enum.FillDirection.Horizontal
	rowLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
	rowLayout.Padding = UDim.new(0, 8)
	rowLayout.Parent = btnRow

	local decision = nil
	local function makeBtn(label, color, order, value)
		local b = Instance.new("TextButton")
		b.Size = UDim2.new(0, 110, 1, 0)
		b.BackgroundColor3 = color
		b.BorderSizePixel = 0
		b.Text = label
		b.TextColor3 = Color3.fromRGB(255, 255, 255)
		b.Font = Enum.Font.GothamMedium
		b.TextSize = 12
		b.LayoutOrder = order
		b.Parent = btnRow
		Instance.new("UICorner", b).CornerRadius = UDim.new(0, 6)
		b.MouseButton1Click:Connect(function()
			if decision then return end
			decision = value
		end)
		return b
	end

	makeBtn("Deny plan",    Color3.fromRGB(140, 50, 50),  1, "deny")
	makeBtn("Approve plan", Color3.fromRGB(50, 110, 150), 2, "approve")

	scrollToBottom()

	while decision == nil do
		if not bubble.Parent then decision = "deny"; break end
		task.wait(0.05)
	end

	for _, child in ipairs(btnRow:GetChildren()) do
		if child:IsA("TextButton") then
			child.AutoButtonColor = false
			child.Active = false
		end
	end
	heading.Text = heading.Text .. "  →  " .. decision:upper()

	return decision
end

-- ── Lazy tree serializer ─────────────────────────────────────
local function serializeTree(obj, depth, lines, counter)
	depth, lines, counter = depth or 0, lines or {}, counter or { n = 0 }
	if counter.n >= CONFIG.MAX_TREE_CHARS then return lines end

	local line = string.rep("  ", depth) .. "[" .. obj.ClassName .. "] " .. obj.Name
	table.insert(lines, line)
	counter.n = counter.n + #line + 1

	local ok, children = pcall(function() return obj:GetChildren() end)
	if ok then
		for _, child in ipairs(children) do
			serializeTree(child, depth + 1, lines, counter)
			if counter.n >= CONFIG.MAX_TREE_CHARS then
				table.insert(lines, string.rep("  ", depth + 1) .. "[... tree truncated ...]")
				return lines
			end
		end
	end
	return lines
end

local function buildTreeDump()
	local sections = {
		"=== ROBLOX GAME TREE (lazy) ===",
		"Place: " .. game.Name .. "  |  PlaceId: " .. tostring(game.PlaceId),
		"Generated: " .. os.date("!%Y-%m-%dT%H:%M:%SZ"),
		"",
		"Tree shows class + name only. Use tools (get_script_source, get_property, etc.) to read content on demand.",
		"",
	}
	for _, svcName in ipairs(CONFIG.SCAN_SERVICES) do
		local ok, svc = pcall(function() return game:GetService(svcName) end)
		if ok and svc then
			table.insert(sections, "── " .. svcName .. " ──")
			for _, l in ipairs(serializeTree(svc, 0)) do table.insert(sections, l) end
			table.insert(sections, "")
		end
	end
	return table.concat(sections, "\n")
end

-- ── Path resolver ────────────────────────────────────────────
local function resolvePath(path)
	if not path or path == "" then return nil, "Empty path" end
	-- @selection / @selected / @sel → the first instance the user has selected.
	if path == "@selection" or path == "@selected" or path == "@sel" then
		local sel = Selection:Get()
		if #sel == 0 then return nil, "No instance is currently selected (@selection)" end
		return sel[1], nil
	end
	-- @character / @player → the live local player's spawned character while
	-- testing (Play/Play Solo), falling back to the StarterCharacter template
	-- (under StarterPlayer) when nothing is running yet.
	if path == "@character" or path == "@player" then
		local Players = game:GetService("Players")
		local lp = Players.LocalPlayer
		if lp and lp.Character then return lp.Character, nil end
		local sp = game:GetService("StarterPlayer")
		local template = sp and sp:FindFirstChild("StarterCharacter")
		if template then return template, nil end
		return nil, "No live player character found (click Play/Play Solo to spawn one) " ..
			"and StarterPlayer.StarterCharacter doesn't exist either (set one up to edit " ..
			"the player's default rig while not running)."
	end
	local parts = {}
	for p in path:gmatch("[^%.]+") do table.insert(parts, p) end
	if #parts == 0 then return nil, "Invalid path: " .. path end

	local current
	local ok, svc = pcall(function() return game:GetService(parts[1]) end)
	if ok and svc then current = svc else current = game:FindFirstChild(parts[1]) end
	if not current then return nil, "Service or root '" .. parts[1] .. "' not found" end

	for i = 2, #parts do
		local child = current:FindFirstChild(parts[i])
		if not child then
			return nil, "'" .. parts[i] .. "' not found under '" .. current:GetFullName() .. "'"
		end
		current = child
	end
	return current, nil
end

local function valueToString(val)
	local t = typeof(val)
	if t == "Vector3" then return string.format("Vector3(%.3f, %.3f, %.3f)", val.X, val.Y, val.Z)
	elseif t == "CFrame" then local p = val.Position; return string.format("CFrame@(%.2f,%.2f,%.2f)", p.X, p.Y, p.Z)
	elseif t == "Color3" then return string.format("Color3(%.2f,%.2f,%.2f)", val.R, val.G, val.B)
	elseif t == "UDim2" or t == "UDim" then return tostring(val)
	elseif t == "BrickColor" or t == "EnumItem" then return tostring(val)
	elseif t == "boolean" then return val and "true" or "false"
	elseif t == "Instance" then return val:GetFullName()
	else return tostring(val) end
end

-- ── Checkpoint bookkeeping ───────────────────────────────────
-- aiWaypoints counts undo steps the AI created this session (one per
-- successful mutating op, one per batch). Checkpoints snapshot it so
-- /rollback can Undo() the right number of times. Best-effort: manual
-- Ctrl+Z / Redo by the user can drift the count.
local aiWaypoints = 0
local inBatch = false      -- true while a batch runs, so sub-ops don't double-count
local checkpoints = {}     -- name -> aiWaypoints value at checkpoint time
local changeLog = {}       -- session change log: { time=..., label=... } per mutation
local CHANGELOG_MAX = 400

local function withWaypoint(label, fn)
	pcall(function() ChangeHistoryService:SetWaypoint("Before " .. label) end)
	local ok, result = pcall(fn)
	pcall(function() ChangeHistoryService:SetWaypoint("After " .. label) end)
	if ok and not inBatch then aiWaypoints = aiWaypoints + 1 end
	if ok then
		changeLog[#changeLog + 1] = { time = os.date("!%H:%M:%S"), label = label }
		while #changeLog > CHANGELOG_MAX do table.remove(changeLog, 1) end
	end
	if not ok then return nil, tostring(result) end
	return result, nil
end
-- ── Plans storage helpers ───────────────────────────────────────────────────
-- Plans live in ServerScriptService.<PLANS_ROOT_NAME>.<PLANS_FOLDER_NAME>.
-- Each plan is a ModuleScript whose Source IS the plan text, so it persists
-- in the place file and is editable in the Explorer. ensurePlansFolder()
-- creates the folder chain on demand and returns the Plans Folder instance.
local function ensurePlansFolder()
	local ok, sss = pcall(function() return game:GetService("ServerScriptService") end)
	if not ok or not sss then return nil, "ServerScriptService unavailable" end

	-- Migration: older builds created a misspelled "UnlimintedAi" folder. If the
	-- correctly-named folder does not exist yet but the legacy one does, rename it
	-- so existing plans carry over instead of being orphaned.
	if not sss:FindFirstChild(CONFIG.PLANS_ROOT_NAME) then
		local legacy = sss:FindFirstChild("UnlimintedAi")
		if legacy and legacy:IsA("Folder") then
			pcall(function() legacy.Name = CONFIG.PLANS_ROOT_NAME end)
		end
	end

	local root = sss:FindFirstChild(CONFIG.PLANS_ROOT_NAME)
	if root and not root:IsA("Folder") then
		return nil, "ServerScriptService." .. CONFIG.PLANS_ROOT_NAME ..
			" exists but is a " .. root.ClassName .. ", not a Folder"
	end
	if not root then
		local cok, made = pcall(function()
			local f = Instance.new("Folder")
			f.Name = CONFIG.PLANS_ROOT_NAME
			f.Parent = sss
			return f
		end)
		if not cok then return nil, "could not create '" .. CONFIG.PLANS_ROOT_NAME .. "': " .. tostring(made) end
		root = made
	end

	local plans = root:FindFirstChild(CONFIG.PLANS_FOLDER_NAME)
	if plans and not plans:IsA("Folder") then
		return nil, "ServerScriptService." .. CONFIG.PLANS_ROOT_NAME .. "." .. CONFIG.PLANS_FOLDER_NAME ..
			" exists but is a " .. plans.ClassName .. ", not a Folder"
	end
	if not plans then
		local cok, made = pcall(function()
			local f = Instance.new("Folder")
			f.Name = CONFIG.PLANS_FOLDER_NAME
			f.Parent = root
			return f
		end)
		if not cok then return nil, "could not create '" .. CONFIG.PLANS_FOLDER_NAME .. "': " .. tostring(made) end
		plans = made
	end

	return plans, nil
end

-- Ensures ServerScriptService.<PLANS_ROOT_NAME>.<SYSTEM_PROMPT_FOLDER_NAME> exists
-- (a sibling of the Plans folder) and returns it. The actual prompt text is
-- written into it on load; read_system_prompt reads it back.
local function ensureSystemPromptFolder()
	local ok, sss = pcall(function() return game:GetService("ServerScriptService") end)
	if not ok or not sss then return nil, "ServerScriptService unavailable" end

	local root = sss:FindFirstChild(CONFIG.PLANS_ROOT_NAME)
	if root and not root:IsA("Folder") then
		return nil, "ServerScriptService." .. CONFIG.PLANS_ROOT_NAME ..
			" exists but is a " .. root.ClassName .. ", not a Folder"
	end
	if not root then
		local cok, made = pcall(function()
			local fdr = Instance.new("Folder")
			fdr.Name = CONFIG.PLANS_ROOT_NAME
			fdr.Parent = sss
			return fdr
		end)
		if not cok then return nil, "could not create '" .. CONFIG.PLANS_ROOT_NAME .. "': " .. tostring(made) end
		root = made
	end

	local sp = root:FindFirstChild(CONFIG.SYSTEM_PROMPT_FOLDER_NAME)
	if sp and not sp:IsA("Folder") then
		return nil, "ServerScriptService." .. CONFIG.PLANS_ROOT_NAME .. "." .. CONFIG.SYSTEM_PROMPT_FOLDER_NAME ..
			" exists but is a " .. sp.ClassName .. ", not a Folder"
	end
	if not sp then
		local cok, made = pcall(function()
			local fdr = Instance.new("Folder")
			fdr.Name = CONFIG.SYSTEM_PROMPT_FOLDER_NAME
			fdr.Parent = root
			return fdr
		end)
		if not cok then return nil, "could not create '" .. CONFIG.SYSTEM_PROMPT_FOLDER_NAME .. "': " .. tostring(made) end
		sp = made
	end

	return sp, nil
end

-- Spawns a fresh default-appearance R15/R6 model via
-- Players:CreateHumanoidModelFromDescription — the same plugin-safe API
-- Studio's own avatar/rig-builder tools use. No asset IDs and no internet
-- fetch needed for the default body, so it works offline too. Anchors every
-- part (so it doesn't ragdoll/fall) and stands it upright in `parent`.
local function buildRigModel(rigTypeName, name, parent, slotIndex)
	local rigTypeOk, rigType = pcall(function() return Enum.HumanoidRigType[rigTypeName or "R15"] end)
	if not rigTypeOk or not rigType then
		return nil, "invalid rig_type '" .. tostring(rigTypeName) .. "' (use R15 or R6)"
	end

	local ok, modelOrErr = pcall(function()
		local Players = game:GetService("Players")
		local desc = Instance.new("HumanoidDescription")
		local model = Players:CreateHumanoidModelFromDescription(desc, rigType)
		model.Name = name
		model.Parent = parent
		for _, d in ipairs(model:GetDescendants()) do
			if d:IsA("BasePart") then d.Anchored = true end
		end
		local hum = model:FindFirstChildOfClass("Humanoid")
		if hum then hum.PlatformStand = true end
		local hrp = model:FindFirstChild("HumanoidRootPart")
		if hrp then model.PrimaryPart = hrp end
		model:PivotTo(CFrame.new((slotIndex or 0) * 6, 5, 0))
		return model
	end)
	if not ok then
		return nil, "Players:CreateHumanoidModelFromDescription failed (" .. tostring(modelOrErr) ..
			"). Your Roblox API version may restrict this — insert a rig from the Toolbox into " ..
			"Workspace." .. CONFIG.RIG_FOLDER_NAME .. " instead and call list_rig_joints on it."
	end
	return modelOrErr, nil
end

-- Ensures Workspace.<RIG_FOLDER_NAME> exists and contains a default rig named
-- <RIG_NAME>, spawning one (only the first time — it won't recreate or
-- duplicate one that's already there, even if you've since edited it).
-- Returns model, err, wasJustCreated.
local function ensureTestRig()
	local ok, ws = pcall(function() return game:GetService("Workspace") end)
	if not ok or not ws then return nil, "Workspace unavailable", false end

	local folder = ws:FindFirstChild(CONFIG.RIG_FOLDER_NAME)
	if folder and not folder:IsA("Folder") then
		return nil, "Workspace." .. CONFIG.RIG_FOLDER_NAME .. " exists but is a " ..
			folder.ClassName .. ", not a Folder", false
	end
	if not folder then
		local cok, made = pcall(function()
			local f = Instance.new("Folder")
			f.Name = CONFIG.RIG_FOLDER_NAME
			f.Parent = ws
			return f
		end)
		if not cok then return nil, "could not create '" .. CONFIG.RIG_FOLDER_NAME .. "': " .. tostring(made), false end
		folder = made
	end

	local existing = folder:FindFirstChild(CONFIG.RIG_NAME)
	if existing then return existing, nil, false end

	local model, berr = buildRigModel(CONFIG.RIG_TYPE, CONFIG.RIG_NAME, folder, 0)
	if not model then return nil, berr, false end
	return model, nil, true
end

-- Returns the plan ModuleScript with the given name, or nil if absent.
local function findPlan(plansFolder, planName)
	if not plansFolder or type(planName) ~= "string" then return nil end
	local child = plansFolder:FindFirstChild(planName)
	if child and child:IsA("ModuleScript") then return child end
	return nil
end

-- Returns the Plans folder ONLY if it already exists (never creates it). Used
-- for cheap read-only context injection so we don't spawn folders every prompt.
local function getPlansFolderIfExists()
	local ok, sss = pcall(function() return game:GetService("ServerScriptService") end)
	if not ok or not sss then return nil end
	local root = sss:FindFirstChild(CONFIG.PLANS_ROOT_NAME)
	if not root then return nil end
	local plans = root:FindFirstChild(CONFIG.PLANS_FOLDER_NAME)
	if plans and plans:IsA("Folder") then return plans end
	return nil
end

-- ── Plan version history ───────────────────────────────────
-- Plans are ModuleScripts, so Ctrl+Z already covers in-session edits. This adds
-- a durable, undo-independent history (kept in plugin settings) so a plan can be
-- diffed or restored even after the undo stack is gone.
-- Shape: { [name] = { { time = "...", source = "..." }, ... } }  (newest last)
local PLAN_HISTORY_KEY       = "unlimited_plan_history"
local PLAN_HISTORY_MAX_VERS  = 10
local PLAN_HISTORY_MAX_BYTES = 400000
local function getPlanHistory()
	local raw = plugin:GetSetting(PLAN_HISTORY_KEY)
	if type(raw) == "string" and raw ~= "" then
		local ok, decoded = pcall(function() return HttpService:JSONDecode(raw) end)
		if ok and type(decoded) == "table" then return decoded end
	end
	return {}
end
local function savePlanHistory(tbl)
	local function totalBytes()
		local ok, enc = pcall(function() return HttpService:JSONEncode(tbl) end)
		return ok and #enc or 0
	end
	local guard = 0
	while totalBytes() > PLAN_HISTORY_MAX_BYTES and guard < 1000 do
		guard = guard + 1
		local biggest, bigName = 0, nil
		for nm, vers in pairs(tbl) do
			if #vers > biggest then biggest = #vers; bigName = nm end
		end
		if not bigName or biggest == 0 then break end
		table.remove(tbl[bigName], 1)
		if #tbl[bigName] == 0 then tbl[bigName] = nil end
	end
	pcall(function() plugin:SetSetting(PLAN_HISTORY_KEY, HttpService:JSONEncode(tbl)) end)
end
local function recordPlanVersion(name, source)
	if type(name) ~= "string" or type(source) ~= "string" then return end
	local hist = getPlanHistory()
	local vers = hist[name] or {}
	if vers[#vers] and vers[#vers].source == source then return end
	vers[#vers + 1] = { time = os.date("!%Y-%m-%d %H:%M:%S"), source = source }
	while #vers > PLAN_HISTORY_MAX_VERS do table.remove(vers, 1) end
	hist[name] = vers
	savePlanHistory(hist)
end

-- ── Line diff (LCS-based, unified-ish) ─────────────────────
local function splitLines(s)
	local t = {}
	for line in (s .. "\n"):gmatch("(.-)\n") do t[#t + 1] = line end
	if #t > 0 and t[#t] == "" then t[#t] = nil end
	return t
end
local function diffLines(oldText, newText)
	local a, b = splitLines(oldText or ""), splitLines(newText or "")
	local n, m = #a, #b
	local L = {}
	for i = 0, n do L[i] = {}; for j = 0, m do L[i][j] = 0 end end
	for i = 1, n do
		for j = 1, m do
			if a[i] == b[j] then L[i][j] = L[i-1][j-1] + 1
			else L[i][j] = math.max(L[i-1][j], L[i][j-1]) end
		end
	end
	local out, i, j = {}, n, m
	while i > 0 and j > 0 do
		if a[i] == b[j] then table.insert(out, 1, "  " .. a[i]); i = i - 1; j = j - 1
		elseif L[i-1][j] >= L[i][j-1] then table.insert(out, 1, "- " .. a[i]); i = i - 1
		else table.insert(out, 1, "+ " .. b[j]); j = j - 1 end
	end
	while i > 0 do table.insert(out, 1, "- " .. a[i]); i = i - 1 end
	while j > 0 do table.insert(out, 1, "+ " .. b[j]); j = j - 1 end
	local adds, dels = 0, 0
	for _, ln in ipairs(out) do
		local c = ln:sub(1, 1)
		if c == "+" then adds = adds + 1 elseif c == "-" then dels = dels + 1 end
	end
	if adds == 0 and dels == 0 then return "(no changes)", 0, 0 end
	return table.concat(out, "\n"), adds, dels
end
-- ── Syntax validator ────────────────────────────────────────
-- Validates Luau source. Tries plain loadstring first; if the source begins
-- with `return ` (typical ModuleScript), also tries wrapped form.
local function validateLuauSyntax(source)
	if not loadstring then
		return true  -- can't validate; assume OK rather than blocking edits
	end
	local fn, err = loadstring(source)
	if fn then return true end

	-- ModuleScript edge case: `return require(...)` style files sometimes
	-- parse only when treated as an expression. Try the wrapped form.
	if source:match("^%s*return%s") then
		local wrapped, werr = loadstring("return (" .. source .. "\n)")
		if wrapped then return true end
		return false, err  -- prefer the unwrapped error message
	end

	return false, err
end

-- ── Output ring buffer (for get_recent_output) ──────────────
local LogService = game:GetService("LogService")
local OUTPUT_RING_SIZE = 500
local outputRing = {}
local outputHead = 1
local errorLog = {}            -- captured runtime errors: { time=..., msg=... }
local ERROR_LOG_MAX = 200

local function pushOutput(line)
	outputRing[outputHead] = line
	outputHead = (outputHead % OUTPUT_RING_SIZE) + 1
end

LogService.MessageOut:Connect(function(msg, msgType)
	local prefix
	if msgType == Enum.MessageType.MessageError then prefix = "[ERROR] "
	elseif msgType == Enum.MessageType.MessageWarning then prefix = "[WARN]  "
	elseif msgType == Enum.MessageType.MessageInfo then prefix = "[INFO]  "
	else prefix = "        " end
	pushOutput(os.date("!%H:%M:%S") .. " " .. prefix .. msg)
	if msgType == Enum.MessageType.MessageError then
		errorLog[#errorLog + 1] = { time = os.date("!%H:%M:%S"), msg = msg }
		while #errorLog > ERROR_LOG_MAX do table.remove(errorLog, 1) end
	end
end)

local function readOutputRing(limit)
	limit = math.min(limit or 50, OUTPUT_RING_SIZE)
	local result = {}
	-- Walk backwards from head to grab the most recent `limit` entries
	local idx = outputHead - 1
	while #result < limit do
		if idx < 1 then idx = OUTPUT_RING_SIZE end
		if outputRing[idx] then
			table.insert(result, 1, outputRing[idx])
		else
			break  -- ring not full yet
		end
		idx = idx - 1
		if idx == outputHead then break end  -- full loop
	end
	return result
end

-- ── Roblox value-string parser ──────────────────────────────
-- Accepts human-friendly strings the AI naturally writes, e.g.:
--   "{1, 0, 0, 50}"        → UDim2.new(1, 0, 0, 50)
--   "{0.5, 10}"            → UDim.new(0.5, 10)
--   "rgb(40, 40, 50)"      → Color3.fromRGB(40, 40, 50)
--   "#ff8800" or "#f80"    → Color3.fromRGB(255, 136, 0)
--   "Enum.Font.Gotham"     → Enum.Font.Gotham
--   "(0, 5, 0)"            → Vector3.new(0, 5, 0)
-- Falls through any unrecognized string unchanged.
local function parseValueString(s)
	if type(s) ~= "string" then return s end

	-- UDim2: {sx, ox, sy, oy}
	local a, b, c, d = s:match("^{%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*}$")
	if a then return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d)) end

	-- UDim: {scale, offset}
	a, b = s:match("^{%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*}$")
	if a then return UDim.new(tonumber(a), tonumber(b)) end

	-- Vector3: (x, y, z)
	a, b, c = s:match("^%(%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*%)$")
	if a then return Vector3.new(tonumber(a), tonumber(b), tonumber(c)) end

	-- Vector2: (x, y)
	a, b = s:match("^%(%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*%)$")
	if a then return Vector2.new(tonumber(a), tonumber(b)) end

	-- Color3: rgb(r,g,b)
	a, b, c = s:match("^rgb%(%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*%)$")
	if a then return Color3.fromRGB(tonumber(a), tonumber(b), tonumber(c)) end

	-- Color3: #rrggbb hex
	a, b, c = s:match("^#(%x%x)(%x%x)(%x%x)$")
	if a then
		return Color3.fromRGB(tonumber(a, 16), tonumber(b, 16), tonumber(c, 16))
	end

	-- Color3: #rgb shorthand
	a, b, c = s:match("^#(%x)(%x)(%x)$")
	if a then
		return Color3.fromRGB(
			tonumber(a .. a, 16), tonumber(b .. b, 16), tonumber(c .. c, 16)
		)
	end

	-- Enum: "Enum.X.Y" or "X.Y"
	local enumClass, enumItem = s:match("^Enum%.(%w+)%.(%w+)$")
	if not enumClass then enumClass, enumItem = s:match("^(%w+)%.(%w+)$") end
	if enumClass then
		local ok, val = pcall(function() return Enum[enumClass][enumItem] end)
		if ok then return val end
	end

	return s
end
-- ── Diff summarizer for patch_script success messages ──────
local function summarizeEdits(applied)
	local MAX_LINE_LEN = 100
	local MAX_LINES_PER_EDIT = 4

	local function truncate(s)
		if #s > MAX_LINE_LEN then return s:sub(1, MAX_LINE_LEN - 3) .. "..." end
		return s
	end

	local function previewLines(text, prefix)
		local lines = {}
		local count = 0
		for line in (text .. "\n"):gmatch("([^\n]*)\n") do
			count = count + 1
			if count <= MAX_LINES_PER_EDIT then
				table.insert(lines, prefix .. truncate(line))
			end
		end
		if count > MAX_LINES_PER_EDIT then
			table.insert(lines, prefix .. "... (+" .. (count - MAX_LINES_PER_EDIT) .. " more lines)")
		end
		return table.concat(lines, "\n")
	end

	local out = {}
	for _, edit in ipairs(applied) do
		table.insert(out, "Edit #" .. edit.index .. ":")
		table.insert(out, previewLines(edit.find, "  - "))
		table.insert(out, previewLines(edit.replace, "  + "))
		table.insert(out, "")
	end
	return table.concat(out, "\n")
end

-- ── Smart property setter (used by create_ui_tree etc.) ─────
local function coerceAndSet(inst, prop, val)
	if type(val) == "string" then
		val = parseValueString(val)
	elseif type(val) == "table" then
		if #val == 4 then
			val = UDim2.new(val[1], val[2], val[3], val[4])
		elseif #val == 3 and prop:find("Color3") then
			val = Color3.fromRGB(val[1], val[2], val[3])
		elseif #val == 3 then
			val = Vector3.new(val[1], val[2], val[3])
		elseif #val == 2 then
			val = Vector2.new(val[1], val[2])
		end
	end
	local ok, err = pcall(function() inst[prop] = val end)
	return ok, err
end

-- ── Networking scaffold helpers ──────────────────────────────
-- Builds the server- or client-side handler source for create_remote's
-- with_handlers option. `opts.params` (array of names) swaps the generic
-- "..." for a real, documented parameter list; `opts.rate_limit` (calls/sec)
-- adds a cheap per-player debounce — the standard first line of defense
-- against a client spamming a RemoteEvent/Function.
local function buildRemoteHandlerSource(side, nm, rtype, opts)
	opts = opts or {}
	local params = (type(opts.params) == "table" and #opts.params > 0) and opts.params or nil
	local paramList = params and table.concat(params, ", ") or "..."
	local rateLimit = tonumber(opts.rate_limit)

	local lines = { 'local remote = game:GetService("ReplicatedStorage"):WaitForChild("' .. nm .. '")' }

	if side == "server" then
		if rateLimit then
			table.insert(lines, "")
			table.insert(lines, "-- Basic per-player rate limiting (" .. rateLimit .. "/sec) — cheap exploit protection.")
			table.insert(lines, "local lastCall = {}")
			table.insert(lines, "local MIN_INTERVAL = 1 / " .. rateLimit)
		end
		table.insert(lines, "")
		if rtype == "RemoteEvent" then
			table.insert(lines, "remote.OnServerEvent:Connect(function(player, " .. paramList .. ")")
			if rateLimit then
				table.insert(lines, "\tlocal now = os.clock()")
				table.insert(lines, "\tif lastCall[player] and now - lastCall[player] < MIN_INTERVAL then return end")
				table.insert(lines, "\tlastCall[player] = now")
				table.insert(lines, "")
			end
			table.insert(lines, "\t-- TODO: validate the arguments before trusting them — clients can send anything.")
			table.insert(lines, "\t-- TODO: handle " .. nm)
			table.insert(lines, "end)")
		else
			table.insert(lines, "remote.OnServerInvoke = function(player, " .. paramList .. ")")
			if rateLimit then
				table.insert(lines, "\tlocal now = os.clock()")
				table.insert(lines, "\tif lastCall[player] and now - lastCall[player] < MIN_INTERVAL then return nil end")
				table.insert(lines, "\tlastCall[player] = now")
				table.insert(lines, "")
			end
			table.insert(lines, "\t-- TODO: validate the arguments before trusting them — clients can send anything.")
			table.insert(lines, "\t-- TODO: handle " .. nm .. " and return a value")
			table.insert(lines, "\treturn nil")
			table.insert(lines, "end")
		end
	else -- client
		table.insert(lines, "")
		if rtype == "RemoteEvent" then
			table.insert(lines, "-- Fire to server:  remote:FireServer(" .. paramList .. ")")
			table.insert(lines, "remote.OnClientEvent:Connect(function(" .. paramList .. ")")
			table.insert(lines, "\t-- TODO: handle " .. nm .. " on client")
			table.insert(lines, "end)")
		else
			table.insert(lines, "-- local result = remote:InvokeServer(" .. paramList .. ")")
		end
	end

	return table.concat(lines, "\n") .. "\n"
end

-- ── Gameplay scaffold helpers (leaderstats, NPC behavior) ────
-- Builds a Script that maintains a `leaderstats` folder per player, backed by
-- DataStoreService with: atomic UpdateAsync writes (no read-then-write races),
-- pcall+retry with backoff on transient errors, save on PlayerRemoving AND on
-- game:BindToClose (server shutdown), a periodic autosave, and a basic
-- single-session lock (stamped with game.JobId + a timestamp, with a 35s
-- staleness window so a crashed server doesn't permanently strand the slot).
-- This is NOT a full session-locking library like ProfileService — it covers
-- the common failure modes, not every edge case a real economy needs.
local function buildLeaderstatsScript(stats, datastoreName, autosaveInterval)
	local statLines = {}
	for _, st in ipairs(stats) do
		table.insert(statLines, string.format(
			"\t{ name = %q, className = %q, default = %s },",
			st.name, st.class_name, type(st.default) == "string" and string.format("%q", st.default) or tostring(st.default)
			))
	end

	local lines = {
		"-- Generated by Unlimited AI (scaffold_leaderstats). Safe to hand-edit.",
		"-- Covers: atomic UpdateAsync saves, retry+backoff, save-on-leave, save-on-",
		"-- shutdown (BindToClose), periodic autosave, and a basic single-session lock",
		"-- with a stale-lock timeout. NOT a full session-locking library (e.g.",
		"-- ProfileService) — swap that in for a real-money economy or anything where",
		"-- duplication would be costly.",
		"",
		'local DataStoreService = game:GetService("DataStoreService")',
		'local Players = game:GetService("Players")',
		string.format('local store = DataStoreService:GetDataStore(%q)', datastoreName),
		"",
		"local STATS = {",
		table.concat(statLines, "\n"),
		"}",
		"",
		string.format("local AUTOSAVE_INTERVAL = %s", tostring(autosaveInterval)),
		"local MAX_ATTEMPTS = 4",
		"local LOCK_STALE_AFTER = 35 -- seconds; lets a new server reclaim a crashed server's lock",
		"",
		"-- Retries transient DataStore errors with backoff. Returns ok, result.",
		"local function dataStoreCall(fn)",
		"\tlocal lastErr",
		"\tfor attempt = 1, MAX_ATTEMPTS do",
		"\t\tlocal ok, result = pcall(fn)",
		"\t\tif ok then return true, result end",
		"\t\tlastErr = result",
		"\t\ttask.wait(attempt * 0.5)",
		"\tend",
		"\treturn false, lastErr",
		"end",
		"",
		"local activePlayers = {} -- userId -> Player, for autosave/shutdown loops",
		"",
		"local function buildDefaultData()",
		"\tlocal data = {}",
		"\tfor _, stat in ipairs(STATS) do data[stat.name] = stat.default end",
		"\treturn data",
		"end",
		"",
		"-- Atomically claims this server's lock and returns the data to load, or nil",
		"-- if another live server still holds it (caller should kick the player).",
		"local function acquireAndLoad(key)",
		"\tlocal claimed, finalData = false, nil",
		"\tlocal ok = dataStoreCall(function()",
		"\t\treturn store:UpdateAsync(key, function(old)",
		"\t\t\tif old and old.lockedBy and old.lockedBy.jobId ~= game.JobId",
		"\t\t\t\tand os.time() - (old.lockedBy.time or 0) < LOCK_STALE_AFTER then",
		"\t\t\t\tclaimed = false",
		"\t\t\t\treturn nil -- cancels the write; we don't own this profile yet",
		"\t\t\tend",
		"\t\t\tclaimed = true",
		"\t\t\tfinalData = (old and old.data) or buildDefaultData()",
		"\t\t\treturn { data = finalData, lockedBy = { jobId = game.JobId, time = os.time() } }",
		"\t\tend)",
		"\tend)",
		"\tif not ok or not claimed then return nil end",
		"\treturn finalData",
		"end",
		"",
		"-- Saves current stat values; `release` also clears the lock (call on leave/shutdown).",
		"local function saveData(player, release)",
		"\tlocal key = \"player_\" .. player.UserId",
		"\tlocal data = {}",
		"\tlocal ls = player:FindFirstChild(\"leaderstats\")",
		"\tfor _, stat in ipairs(STATS) do",
		"\t\tlocal obj = ls and ls:FindFirstChild(stat.name)",
		"\t\tif obj then data[stat.name] = obj.Value else data[stat.name] = stat.default end",
		"\tend",
		"\tlocal ok = dataStoreCall(function()",
		"\t\treturn store:UpdateAsync(key, function()",
		"\t\t\tif release then return { data = data, lockedBy = nil } end",
		"\t\t\treturn { data = data, lockedBy = { jobId = game.JobId, time = os.time() } }",
		"\t\tend)",
		"\tend)",
		"\tif not ok then warn(\"[leaderstats] failed to save \" .. player.Name) end",
		"end",
		"",
		"Players.PlayerAdded:Connect(function(player)",
		"\tlocal key = \"player_\" .. player.UserId",
		"\tlocal data = acquireAndLoad(key)",
		"\tif not data then",
		"\t\tplayer:Kick(\"Your save data is still loading on another server — rejoin in a few seconds.\")",
		"\t\treturn",
		"\tend",
		"",
		"\tlocal ls = Instance.new(\"Folder\")",
		"\tls.Name = \"leaderstats\"",
		"\tfor _, stat in ipairs(STATS) do",
		"\t\tlocal obj = Instance.new(stat.className)",
		"\t\tobj.Name = stat.name",
		"\t\tlocal v = data[stat.name]",
		"\t\tif v == nil then v = stat.default end",
		"\t\tobj.Value = v",
		"\t\tobj.Parent = ls",
		"\tend",
		"\tls.Parent = player",
		"\tactivePlayers[player.UserId] = player",
		"end)",
		"",
		"Players.PlayerRemoving:Connect(function(player)",
		"\tactivePlayers[player.UserId] = nil",
		"\tsaveData(player, true)",
		"end)",
		"",
		"-- Periodic autosave keeps the lock fresh and limits loss if the server crashes.",
		"task.spawn(function()",
		"\twhile true do",
		"\t\ttask.wait(AUTOSAVE_INTERVAL)",
		"\t\tfor _, player in pairs(activePlayers) do",
		"\t\t\tsaveData(player, false)",
		"\t\tend",
		"\tend",
		"end)",
		"",
		"game:BindToClose(function()",
		"\tfor _, player in pairs(activePlayers) do",
		"\t\tsaveData(player, true)",
		"\tend",
		"end)",
		"",
	}
	return table.concat(lines, "\n") .. "\n"
end

-- Builds a Script (parented under the rig) driving simple PathfindingService
-- movement: patrol a list of points, wander near the spawn point, or chase
-- whichever player is currently nearest. No combat — that's a deliberate TODO.
local function buildNpcScript(mode, waypoints, wanderRadius, chaseRange, loop)
	local lines = {
		"-- Generated by Unlimited AI (scaffold_npc_behavior). Safe to hand-edit.",
		'local PathfindingService = game:GetService("PathfindingService")',
		'local Players = game:GetService("Players")',
		"local rig = script.Parent",
		'local humanoid = rig:WaitForChild("Humanoid")',
		'local rootPart = rig:WaitForChild("HumanoidRootPart")',
		"",
		"local function walkTo(targetPos)",
		"\tlocal path = PathfindingService:CreatePath({",
		"\t\tAgentRadius = 2, AgentHeight = 5, AgentCanJump = true,",
		"\t})",
		"\tlocal ok = pcall(function() path:ComputeAsync(rootPart.Position, targetPos) end)",
		"\tif not ok or path.Status ~= Enum.PathStatus.Success then return false end",
		"",
		"\tfor _, waypoint in ipairs(path:GetWaypoints()) do",
		"\t\tif waypoint.Action == Enum.PathWaypointAction.Jump then",
		"\t\t\thumanoid.Jump = true",
		"\t\tend",
		"\t\thumanoid:MoveTo(waypoint.Position)",
		"\t\tlocal reached = humanoid.MoveToFinished:Wait(3)",
		"\t\tif not reached then return false end",
		"\tend",
		"\treturn true",
		"end",
		"",
	}

	if mode == "patrol" then
		table.insert(lines, "local WAYPOINTS = {")
		for _, wp in ipairs(waypoints) do
			table.insert(lines, string.format("\tVector3.new(%s, %s, %s),", wp[1], wp[2], wp[3]))
		end
		table.insert(lines, "}")
		table.insert(lines, "")
		table.insert(lines, loop and "while true do" or "do")
		table.insert(lines, "\tfor _, point in ipairs(WAYPOINTS) do")
		table.insert(lines, "\t\twalkTo(point)")
		table.insert(lines, "\t\ttask.wait(1)")
		table.insert(lines, "\tend")
		table.insert(lines, "end")

	elseif mode == "wander" then
		table.insert(lines, string.format("local WANDER_RADIUS = %s", wanderRadius))
		table.insert(lines, "local homePosition = rootPart.Position")
		table.insert(lines, "")
		table.insert(lines, "while true do")
		table.insert(lines, "\tlocal angle = math.random() * math.pi * 2")
		table.insert(lines, "\tlocal dist = math.random() * WANDER_RADIUS")
		table.insert(lines, "\tlocal target = homePosition + Vector3.new(math.cos(angle) * dist, 0, math.sin(angle) * dist)")
		table.insert(lines, "\twalkTo(target)")
		table.insert(lines, "\ttask.wait(math.random(2, 5))")
		table.insert(lines, "end")

	elseif mode == "chase_nearest_player" then
		table.insert(lines, string.format("local CHASE_RANGE = %s", chaseRange))
		table.insert(lines, "")
		table.insert(lines, "local function findNearestTarget()")
		table.insert(lines, "\tlocal nearest, nearestDist = nil, CHASE_RANGE")
		table.insert(lines, "\tfor _, player in ipairs(Players:GetPlayers()) do")
		table.insert(lines, "\t\tlocal char = player.Character")
		table.insert(lines, "\t\tlocal hrp = char and char:FindFirstChild(\"HumanoidRootPart\")")
		table.insert(lines, "\t\tif hrp then")
		table.insert(lines, "\t\t\tlocal dist = (hrp.Position - rootPart.Position).Magnitude")
		table.insert(lines, "\t\t\tif dist < nearestDist then nearest, nearestDist = hrp, dist end")
		table.insert(lines, "\t\tend")
		table.insert(lines, "\tend")
		table.insert(lines, "\treturn nearest")
		table.insert(lines, "end")
		table.insert(lines, "")
		table.insert(lines, "while true do")
		table.insert(lines, "\tlocal target = findNearestTarget()")
		table.insert(lines, "\tif target then")
		table.insert(lines, "\t\twalkTo(target.Position)")
		table.insert(lines, "\t\t-- TODO: add attack/interaction logic once in range")
		table.insert(lines, "\telse")
		table.insert(lines, "\t\ttask.wait(1)")
		table.insert(lines, "\tend")
		table.insert(lines, "end")
	end

	return table.concat(lines, "\n") .. "\n"
end

-- ── Rig animation helpers ────────────────────────────────────
-- Builds a childPartName -> parentPartName map from every Motor6D found
-- under `rig`, plus a partName -> BasePart lookup and the detected root
-- part (the one that's a Motor6D Part0 somewhere but never a Part1).
local function buildRigJointMap(rig)
	local parentOf  = {}  -- childPartName -> parentPartName
	local partByName = {} -- partName -> BasePart (first match wins)
	local isChild   = {}  -- partName -> true if it's some Motor6D's Part1
	local isParent  = {}  -- partName -> true if it's some Motor6D's Part0

	for _, d in ipairs(rig:GetDescendants()) do
		if d:IsA("BasePart") and not partByName[d.Name] then
			partByName[d.Name] = d
		end
	end
	for _, d in ipairs(rig:GetDescendants()) do
		if d:IsA("Motor6D") and d.Part0 and d.Part1 then
			parentOf[d.Part1.Name] = d.Part0.Name
			isChild[d.Part1.Name]  = true
			isParent[d.Part0.Name] = true
		end
	end

	local root = nil
	if rig.PrimaryPart and isParent[rig.PrimaryPart.Name] and not isChild[rig.PrimaryPart.Name] then
		root = rig.PrimaryPart.Name
	end
	if not root then
		for nm in pairs(isParent) do
			if not isChild[nm] then root = nm; break end
		end
	end
	return parentOf, partByName, root
end

-- Walks parentOf from `partName` up to `root`, returning the chain
-- root..partName inclusive, or nil if partName isn't reachable from root.
local function rigChainToRoot(parentOf, root, partName)
	local chain = { partName }
	local cur = partName
	local guard = 0
	while cur ~= root and parentOf[cur] and guard < 64 do
		cur = parentOf[cur]
		table.insert(chain, 1, cur)
		guard = guard + 1
	end
	if chain[1] ~= root then return nil end
	return chain
end

-- poseSpecs: { partName -> { position={x,y,z}?, rotation={rx,ry,rz} (deg)?, weight=n? } }
-- Returns a tree: { name=.., children={[childName]=node,...}, cframe=CFrame, weight=n }
-- covering every ancestor chain from root down to each named part. Parts not
-- explicitly posed get an identity CFrame so the chain stays intact.
local function buildPoseTree(parentOf, root, poseSpecs)
	local nodes = {}
	local function getOrCreate(nm)
		if not nodes[nm] then
			nodes[nm] = { name = nm, children = {}, cframe = CFrame.new(), weight = 1 }
		end
		return nodes[nm]
	end
	local rootNode = getOrCreate(root)
	local skipped = {}
	for partName, spec in pairs(poseSpecs) do
		local chain = rigChainToRoot(parentOf, root, partName)
		if not chain then
			table.insert(skipped, partName)
		else
			local prev = nil
			for _, nm in ipairs(chain) do
				local node = getOrCreate(nm)
				if prev then prev.children[nm] = node end
				prev = node
			end
			local leaf = nodes[partName]
			local pos = type(spec) == "table" and spec.position or nil
			local rot = type(spec) == "table" and spec.rotation or nil
			local cf = CFrame.new(pos and pos[1] or 0, pos and pos[2] or 0, pos and pos[3] or 0)
			if rot then
				cf = cf * CFrame.Angles(math.rad(rot[1] or 0), math.rad(rot[2] or 0), math.rad(rot[3] or 0))
			end
			leaf.cframe = cf
			leaf.weight = (type(spec) == "table" and spec.weight) or 1
		end
	end
	return rootNode, skipped
end

local function instantiatePoseTree(node, parentInst)
	local pose = Instance.new("Pose")
	pose.Name = node.name
	pose.CFrame = node.cframe
	pose.Weight = node.weight
	pose.EasingStyle = Enum.PoseEasingStyle.Linear
	pose.Parent = parentInst
	for _, child in pairs(node.children) do
		instantiatePoseTree(child, pose)
	end
	return pose
end

-- ── UI animation helpers ─────────────────────────────────────
-- Converts a JSON-ish value (same shapes set_property/create_ui_tree accept:
-- "{sx,ox,sy,oy}", "rgb(r,g,b)", "#hex", "(x,y,z)", "(x,y)", "Enum.X.Y", or a
-- raw 2/3/4-length number array) into a Luau SOURCE expression string, for
-- embedding into a generated script. Returns nil if it can't be converted.
local function valueToLuauLiteral(prop, val)
	if type(val) == "string" then
		local a, b, c, d = val:match("^{%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*}$")
		if a then return string.format("UDim2.new(%s,%s,%s,%s)", a, b, c, d) end
		a, b = val:match("^{%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*}$")
		if a then return string.format("UDim.new(%s,%s)", a, b) end
		a, b, c = val:match("^%(%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*%)$")
		if a then return string.format("Vector3.new(%s,%s,%s)", a, b, c) end
		a, b = val:match("^%(%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*%)$")
		if a then return string.format("Vector2.new(%s,%s)", a, b) end
		a, b, c = val:match("^rgb%(%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*%)$")
		if a then return string.format("Color3.fromRGB(%s,%s,%s)", a, b, c) end
		a, b, c = val:match("^#(%x%x)(%x%x)(%x%x)$")
		if a then return string.format("Color3.fromRGB(%d,%d,%d)", tonumber(a, 16), tonumber(b, 16), tonumber(c, 16)) end
		a, b, c = val:match("^#(%x)(%x)(%x)$")
		if a then
			return string.format("Color3.fromRGB(%d,%d,%d)",
				tonumber(a .. a, 16), tonumber(b .. b, 16), tonumber(c .. c, 16))
		end
		if val:match("^Enum%.%w+%.%w+$") then return val end
		return string.format("%q", val)
	elseif type(val) == "boolean" or type(val) == "number" then
		return tostring(val)
	elseif type(val) == "table" then
		if #val == 4 then
			return string.format("UDim2.new(%s,%s,%s,%s)", val[1], val[2], val[3], val[4])
		elseif #val == 3 and prop:find("Color") then
			return string.format("Color3.fromRGB(%s,%s,%s)", val[1], val[2], val[3])
		elseif #val == 3 then
			return string.format("Vector3.new(%s,%s,%s)", val[1], val[2], val[3])
		elseif #val == 2 then
			return string.format("Vector2.new(%s,%s)", val[1], val[2])
		end
	end
	return nil
end

-- Renders a {prop = value, ...} args table as a Luau table-literal source string.
local function propsToLuauTable(properties)
	local parts = {}
	for prop, val in pairs(properties) do
		local lit = valueToLuauLiteral(prop, val)
		if not lit then return nil, "couldn't convert property '" .. tostring(prop) .. "'" end
		table.insert(parts, string.format("[%q] = %s", prop, lit))
	end
	return "{ " .. table.concat(parts, ", ") .. " }"
end

-- Builds the full LocalScript source for create_ui_animation_script. `steps`
-- is the same shape tween_ui takes per-step; `trigger` is appear|loop|hover|click.
local function buildUiAnimScript(steps, trigger, reverseOnLeave)
	local stepLines = {}
	for i, step in ipairs(steps) do
		if type(step.properties) ~= "table" or next(step.properties) == nil then
			return nil, "step #" .. i .. " needs a non-empty 'properties' object"
		end
		local propsLit, perr = propsToLuauTable(step.properties)
		if not propsLit then return nil, "step #" .. i .. ": " .. perr end
		local style = step.easing_style or "Quad"
		local direction = step.easing_direction or "Out"
		local styleOk = pcall(function() return Enum.EasingStyle[style] end)
		local dirOk = pcall(function() return Enum.EasingDirection[direction] end)
		if not styleOk then return nil, "step #" .. i .. ": invalid easing_style '" .. tostring(style) .. "'" end
		if not dirOk then return nil, "step #" .. i .. ": invalid easing_direction '" .. tostring(direction) .. "'" end
		table.insert(stepLines, string.format(
			"\t{ duration = %s, style = Enum.EasingStyle.%s, direction = Enum.EasingDirection.%s, delay = %s, props = %s },",
			tonumber(step.duration) or 0.3, style, direction, tonumber(step.delay) or 0, propsLit
			))
	end

	local lines = {
		"-- Generated by Unlimited AI (create_ui_animation_script). Safe to hand-edit.",
		"local TweenService = game:GetService(\"TweenService\")",
		"local target = script.Parent",
		"",
		"local STEPS = {",
		table.concat(stepLines, "\n"),
		"}",
		"",
		"local function playStep(step)",
		"\tlocal info = TweenInfo.new(step.duration, step.style, step.direction, 0, false, step.delay)",
		"\tlocal tween = TweenService:Create(target, info, step.props)",
		"\ttween:Play()",
		"\ttween.Completed:Wait()",
		"end",
		"",
		"local function playSequence()",
		"\tfor _, step in ipairs(STEPS) do",
		"\t\tplayStep(step)",
		"\tend",
		"end",
		"",
	}

	if trigger == "appear" then
		table.insert(lines, "playSequence()")
	elseif trigger == "loop" then
		table.insert(lines, "while target.Parent do")
		table.insert(lines, "\tplaySequence()")
		table.insert(lines, "end")
	elseif trigger == "hover" then
		table.insert(lines, "local ORIGINAL = {}")
		table.insert(lines, "for _, step in ipairs(STEPS) do")
		table.insert(lines, "\tfor prop in pairs(step.props) do")
		table.insert(lines, "\t\tif ORIGINAL[prop] == nil then ORIGINAL[prop] = target[prop] end")
		table.insert(lines, "\tend")
		table.insert(lines, "end")
		table.insert(lines, "")
		table.insert(lines, "target.MouseEnter:Connect(function()")
		table.insert(lines, "\tplaySequence()")
		table.insert(lines, "end)")
		if reverseOnLeave then
			table.insert(lines, "")
			table.insert(lines, "target.MouseLeave:Connect(function()")
			table.insert(lines, "\tlocal info = TweenInfo.new(0.2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)")
			table.insert(lines, "\tTweenService:Create(target, info, ORIGINAL):Play()")
			table.insert(lines, "end)")
		end
	elseif trigger == "click" then
		table.insert(lines, "target.MouseButton1Click:Connect(function()")
		table.insert(lines, "\tplaySequence()")
		table.insert(lines, "end)")
	end

	return table.concat(lines, "\n") .. "\n"
end

-- ── Cancellation flag (shared with the agent loop) ──────────
local cancelToken = { cancelled = false }
local function resetCancel() cancelToken = { cancelled = false } end
local function requestCancel() cancelToken.cancelled = true end
local function isCancelled() return cancelToken.cancelled end
-- ── Tool executor ────────────────────────────────────────────
local function executeTool(name, args)
	args = args or {}

	-- ── High-risk action gate ────────────────────────────────
	-- Confirm with the user before running destructive or code-executing tools.
	-- Skipped if args.dry_run is already true (preview-only, not destructive),
	-- or if args.__gated is true (already approved by batch pre-flight).
	if HIGH_RISK_TOOLS[name] and not args.dry_run then
		if isBypassActive() then
			-- Bypass active: skip the bubble but still emit a chat trail.
			-- This runs even for batch sub-ops (which arrive with __gated set),
			-- so every auto-executed action leaves a visible record.
			local target = (args and (args.path or args.parent)) or "?"
			bubbleOrder = bubbleOrder + 1
			local note = Instance.new("Frame")
			note.Size = UDim2.new(1, 0, 0, 0)
			note.AutomaticSize = Enum.AutomaticSize.Y
			note.BackgroundColor3 = Color3.fromRGB(80, 35, 35)
			note.BorderSizePixel = 0
			note.LayoutOrder = bubbleOrder
			note.Parent = scroll
			Instance.new("UICorner", note).CornerRadius = UDim.new(0, 6)

			local pad = Instance.new("UIPadding")
			pad.PaddingTop = UDim.new(0, 4); pad.PaddingBottom = UDim.new(0, 4)
			pad.PaddingLeft = UDim.new(0, 10); pad.PaddingRight = UDim.new(0, 10)
			pad.Parent = note

			local label = Instance.new("TextLabel")
			label.Size = UDim2.new(1, 0, 0, 18)
			label.BackgroundTransparency = 1
			label.Text = "⚡ BYPASS · " .. name .. " · " .. tostring(target)
			label.TextColor3 = Color3.fromRGB(255, 180, 180)
			label.TextSize = 11
			label.Font = Enum.Font.GothamBold
			label.TextXAlignment = Enum.TextXAlignment.Left
			label.Parent = note

			scrollToBottom()
		elseif not args.__gated then
			local verdict = confirmAction(name, args)
			if verdict == "deny" then
				return "🛑 Denied by user."
			elseif verdict == "dryrun" then
				args.dry_run = true
			end
		end
	end
	-- ── Universal dry-run guard ──────────────────────────────
	local MUTATING_TOOLS = {
		set_script_source = true, patch_script = true, create_script = true,
		delete_instance = true, set_property = true, create_instance = true,
		rename_instance = true, move_instance = true, clone_instance = true,
		set_selection = true, insert_model = true, run_script = true,
		create_ui_tree = true, apply_layout = true,
		set_attribute = true, add_tag = true, remove_tag = true,
		duplicate_array = true, align_instances = true,
		distribute_instances = true, weld_parts = true,
		create_remote = true, revert_script = true,
		write_plan = true, delete_plan = true, restore_plan = true,
		create_rig_animation = true, tween_ui = true, create_ui_animation_script = true,
		spawn_rig = true, edit_terrain = true, create_constraint = true, create_sound = true,
		set_scene_mood = true, scaffold_leaderstats = true, scaffold_npc_behavior = true,
		apply_surface = true,
		use_library_icon = true,
	}
	if args.dry_run and MUTATING_TOOLS[name] then
		local preview = HttpService:JSONEncode(args)
		if #preview > 400 then preview = preview:sub(1, 400) .. "..." end
		return "DRY RUN — would call " .. name .. "(" .. preview .. ")\nNo changes made."
	end
	-- ── Ui Creation  ─────────────────────────────────────────

	if name == "present_plan" then
		if type(args.steps) ~= "table" or #args.steps == 0 then
			return "ERROR: 'steps' must be a non-empty array of {tool, target, description}"
		end
		for i, st in ipairs(args.steps) do
			if type(st.tool) ~= "string" or st.tool == "" then
				return "ERROR: steps[" .. i .. "] needs a 'tool' name"
			end
		end

		local decision
		if isBypassActive() then
			decision = "approve"
			bubbleOrder = bubbleOrder + 1
			local note = Instance.new("Frame")
			note.Size = UDim2.new(1, 0, 0, 0)
			note.AutomaticSize = Enum.AutomaticSize.Y
			note.BackgroundColor3 = Color3.fromRGB(80, 35, 35)
			note.BorderSizePixel = 0
			note.LayoutOrder = bubbleOrder
			note.Parent = scroll
			Instance.new("UICorner", note).CornerRadius = UDim.new(0, 6)
			local pad = Instance.new("UIPadding")
			pad.PaddingTop = UDim.new(0, 4); pad.PaddingBottom = UDim.new(0, 4)
			pad.PaddingLeft = UDim.new(0, 10); pad.PaddingRight = UDim.new(0, 10)
			pad.Parent = note
			local label = Instance.new("TextLabel")
			label.Size = UDim2.new(1, 0, 0, 18)
			label.BackgroundTransparency = 1
			label.Text = "⚡ BYPASS · present_plan · " .. #args.steps .. " step(s) auto-approved"
			label.TextColor3 = Color3.fromRGB(255, 180, 180)
			label.TextSize = 11
			label.Font = Enum.Font.GothamBold
			label.TextXAlignment = Enum.TextXAlignment.Left
			label.Parent = note
			scrollToBottom()
		else
			decision = confirmPlan(args.steps)
		end

		if decision == "deny" then
			return "❌ Plan denied by the user. Do not proceed with these steps — ask what they'd like instead."
		end

		local count = 0
		for _, st in ipairs(args.steps) do
			if type(st.target) == "string" and st.target ~= "" then
				sessionAllow[st.tool .. "|" .. st.target] = true
				count = count + 1
			end
		end
		return "✅ Plan approved (" .. count .. " step(s) pre-approved for this session). Proceed now — " ..
			"a later call only skips its individual prompt if both the tool name AND the target " ..
			"(args.path or args.parent, exactly as declared here) match a step below. Anything that " ..
			"doesn't match, or wasn't in the plan, still prompts normally."

	elseif name == "create_ui_tree" then
		local parent, err = resolvePath(args.parent)
		if not parent then return "ERROR: " .. err end
		if type(args.tree) ~= "table" or not args.tree.class then
			return "ERROR: 'tree' must be an object with a 'class' field"
		end

		local created = { count = 0, errors = {} }

		local function build(node, parentInst)
			if type(node) ~= "table" or not node.class then
				table.insert(created.errors, "node missing 'class'")
				return nil
			end
			local ok, inst = pcall(Instance.new, node.class)
			if not ok then
				table.insert(created.errors, "Instance.new(" .. tostring(node.class) .. "): " .. tostring(inst))
				return nil
			end

			inst.Name = node.name or node.class

			-- Apply props (accepts "props" or "properties")
			local props = node.props or node.properties
			if type(props) == "table" then
				for prop, val in pairs(props) do
					local setOk, setErr = coerceAndSet(inst, prop, val)
					if not setOk then
						table.insert(created.errors,
							inst:GetFullName() .. "." .. prop .. ": " .. tostring(setErr))
					end
				end
			end

			inst.Parent = parentInst
			created.count = created.count + 1

			if type(node.children) == "table" then
				for _, child in ipairs(node.children) do
					build(child, inst)
				end
			end
			return inst
		end

		local root
		local _, buildErr = withWaypoint("AI create UI tree", function()
			root = build(args.tree, parent)
		end)
		if buildErr then return "ERROR building tree: " .. buildErr end
		if not root then return "ERROR: root instance failed to create" end

		Selection:Set({ root })

		local msg = string.format("✅ Created UI tree at %s — %d instance(s), root: %s",
			args.parent, created.count, root:GetFullName())
		if #created.errors > 0 then
			msg = msg .. "\n⚠ " .. #created.errors .. " property/build warning(s):"
			for i = 1, math.min(5, #created.errors) do
				msg = msg .. "\n  • " .. created.errors[i]
			end
			if #created.errors > 5 then
				msg = msg .. "\n  ...and " .. (#created.errors - 5) .. " more."
			end
		end
		return msg

	elseif name == "describe_ui_layout" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if not (inst:IsA("LayerCollector") or inst:IsA("GuiObject")) then
			return "ERROR: " .. args.path .. " is " .. inst.ClassName ..
				" — must be a ScreenGui, SurfaceGui, BillboardGui, or GuiObject"
		end

		local maxDepth   = tonumber(args.max_depth) or 8
		local showHidden = args.show_hidden == true
		local lines = {}

		-- Approximate text width (no API for measuring text exactly)
		local function estimateTextWidth(text, textSize)
			if not text or text == "" then return 0 end
			return math.floor(#text * (textSize or 14) * 0.55)
		end

		-- Detect potentially problematic states
		local function diagnose(node)
			local warnings = {}
			if node:IsA("GuiObject") then
				if not node.Visible and not showHidden then
					table.insert(warnings, "hidden")
				end
				local absSize = node.AbsoluteSize
				local absPos  = node.AbsolutePosition

				if absSize.X < 1 or absSize.Y < 1 then
					table.insert(warnings, "zero-size (" ..
						math.floor(absSize.X) .. "×" .. math.floor(absSize.Y) .. ")")
				end

				-- Out-of-bounds check vs nearest LayerCollector
				local layer = node:FindFirstAncestorWhichIsA("LayerCollector")
				if layer then
					local screenSize = layer.AbsoluteSize
					if absPos.X + absSize.X < 0 or absPos.Y + absSize.Y < 0 or
						absPos.X > screenSize.X or absPos.Y > screenSize.Y then
						table.insert(warnings, "off-screen")
					end
				end

				if node:IsA("TextLabel") or node:IsA("TextButton") or node:IsA("TextBox") then
					if node.TextScaled == false and node.Text and node.Text ~= "" then
						local estW = estimateTextWidth(node.Text, node.TextSize)
						if estW > absSize.X + 2 then
							table.insert(warnings, "text may overflow (~" .. estW .. "px in " ..
								math.floor(absSize.X) .. "px)")
						end
					end
				end
			end
			return warnings
		end

		local function describeNode(node, depth)
			if depth > maxDepth then
				table.insert(lines, string.rep("  ", depth) .. "[... depth limit ...]")
				return
			end

			local prefix = string.rep("  ", depth)
			local line = prefix .. "[" .. node.ClassName .. "] " .. node.Name

			if node:IsA("GuiObject") then
				local ap, as = node.AbsolutePosition, node.AbsoluteSize
				line = line .. string.format("  @(%d, %d) → %dx%d",
					math.floor(ap.X), math.floor(ap.Y),
					math.floor(as.X), math.floor(as.Y))
				if node.ZIndex and node.ZIndex ~= 1 then
					line = line .. "  z=" .. node.ZIndex
				end
			elseif node:IsA("LayerCollector") then
				local as = node.AbsoluteSize
				line = line .. string.format("  (canvas %dx%d, %s)",
					math.floor(as.X), math.floor(as.Y),
					node.Enabled and "enabled" or "DISABLED")
			end

			-- Layout helper info
			if node:IsA("UIListLayout") then
				line = line .. "  [" .. tostring(node.FillDirection) ..
					", padding=" .. tostring(node.Padding) .. "]"
			elseif node:IsA("UIGridLayout") then
				line = line .. "  [grid " .. tostring(node.CellSize) ..
					", padding=" .. tostring(node.CellPadding) .. "]"
			elseif node:IsA("UIPadding") then
				line = line .. "  [pad L=" .. tostring(node.PaddingLeft) ..
					" R=" .. tostring(node.PaddingRight) ..
					" T=" .. tostring(node.PaddingTop) ..
					" B=" .. tostring(node.PaddingBottom) .. "]"
			elseif node:IsA("UICorner") then
				line = line .. "  [radius=" .. tostring(node.CornerRadius) .. "]"
			elseif node:IsA("UIStroke") then
				line = line .. "  [stroke " .. tostring(node.Thickness) .. "px]"
			elseif node:IsA("UIAspectRatioConstraint") then
				line = line .. "  [aspect=" .. tostring(node.AspectRatio) .. "]"
			end

			-- Text preview
			if node:IsA("TextLabel") or node:IsA("TextButton") or node:IsA("TextBox") then
				local txt = node.Text or ""
				if #txt > 40 then txt = txt:sub(1, 40) .. "..." end
				if txt ~= "" then
					line = line .. '  "' .. txt:gsub("\n", "\\n") .. '"'
				end
			end

			-- Warnings
			local warnings = diagnose(node)
			if #warnings > 0 then
				line = line .. "  ⚠ " .. table.concat(warnings, ", ")
			end

			table.insert(lines, line)

			-- Recurse, but skip hidden subtrees unless requested
			if node:IsA("GuiObject") and not node.Visible and not showHidden then
				return
			end
			local ok, children = pcall(function() return node:GetChildren() end)
			if ok then
				for _, child in ipairs(children) do
					describeNode(child, depth + 1)
				end
			end
		end

		table.insert(lines, "UI layout of " .. args.path .. ":")
		describeNode(inst, 0)

		if #lines == 1 then
			return lines[1] .. "\n(no children)"
		end
		return table.concat(lines, "\n") ..
			"\n\nNote: positions/sizes are computed at the current viewport. " ..
			"Resize Studio to test other resolutions."
	elseif name == "apply_layout" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if not (inst:IsA("GuiObject") or inst:IsA("LayerCollector")) then
			return "ERROR: " .. args.path .. " is " .. inst.ClassName ..
				" — apply_layout only works on GuiObjects and ScreenGuis"
		end

		local layoutType = args.type
		if not layoutType then
			return "ERROR: 'type' is required. Options: vertical_list | horizontal_list | grid"
		end

		local created = {}

		local _, applyErr = withWaypoint("AI apply layout", function()
			-- Layout container
			if layoutType == "vertical_list" or layoutType == "horizontal_list" then
				local existing = inst:FindFirstChildOfClass("UIListLayout")
				local layout = existing or Instance.new("UIListLayout")
				layout.Name = "Layout"
				layout.FillDirection = (layoutType == "vertical_list")
					and Enum.FillDirection.Vertical
					or Enum.FillDirection.Horizontal
				if args.padding then
					layout.Padding = UDim.new(0, tonumber(args.padding) or 0)
				end
				if args.alignment then
					local align = args.alignment:lower()
					if align == "center" then
						layout.HorizontalAlignment = Enum.HorizontalAlignment.Center
						layout.VerticalAlignment   = Enum.VerticalAlignment.Center
					elseif align == "start" or align == "left" or align == "top" then
						layout.HorizontalAlignment = Enum.HorizontalAlignment.Left
						layout.VerticalAlignment   = Enum.VerticalAlignment.Top
					elseif align == "end" or align == "right" or align == "bottom" then
						layout.HorizontalAlignment = Enum.HorizontalAlignment.Right
						layout.VerticalAlignment   = Enum.VerticalAlignment.Bottom
					end
				end
				if args.sort_order then
					local s = args.sort_order:lower()
					layout.SortOrder = (s == "name") and Enum.SortOrder.Name or Enum.SortOrder.LayoutOrder
				end
				layout.Parent = inst
				if not existing then table.insert(created, "UIListLayout") end

			elseif layoutType == "grid" then
				local existing = inst:FindFirstChildOfClass("UIGridLayout")
				local layout = existing or Instance.new("UIGridLayout")
				layout.Name = "Grid"
				if args.cell_size and type(args.cell_size) == "table" and #args.cell_size == 4 then
					layout.CellSize = UDim2.new(
						args.cell_size[1], args.cell_size[2],
						args.cell_size[3], args.cell_size[4]
					)
				end
				if args.cell_padding then
					local p = tonumber(args.cell_padding) or 0
					layout.CellPadding = UDim2.new(0, p, 0, p)
				end
				if args.fill_direction then
					layout.FillDirection = (args.fill_direction:lower() == "horizontal")
						and Enum.FillDirection.Horizontal
						or Enum.FillDirection.Vertical
				end
				layout.Parent = inst
				if not existing then table.insert(created, "UIGridLayout") end

			else
				error("Unknown layout type: " .. tostring(layoutType))
			end

			-- Padding (inset from edges)
			if args.inset then
				local existing = inst:FindFirstChildOfClass("UIPadding")
				local padding = existing or Instance.new("UIPadding")
				padding.Name = "Padding"
				local p
				if type(args.inset) == "number" then
					p = UDim.new(0, args.inset)
					padding.PaddingTop = p; padding.PaddingBottom = p
					padding.PaddingLeft = p; padding.PaddingRight = p
				elseif type(args.inset) == "table" then
					padding.PaddingTop    = UDim.new(0, args.inset.top    or 0)
					padding.PaddingBottom = UDim.new(0, args.inset.bottom or 0)
					padding.PaddingLeft   = UDim.new(0, args.inset.left   or 0)
					padding.PaddingRight  = UDim.new(0, args.inset.right  or 0)
				end
				padding.Parent = inst
				if not existing then table.insert(created, "UIPadding") end
			end

			-- Corner radius
			if args.corner_radius and inst:IsA("GuiObject") then
				local existing = inst:FindFirstChildOfClass("UICorner")
				local corner = existing or Instance.new("UICorner")
				corner.Name = "Corner"
				corner.CornerRadius = UDim.new(0, tonumber(args.corner_radius) or 0)
				corner.Parent = inst
				if not existing then table.insert(created, "UICorner") end
			end

			-- Stroke
			if args.stroke and inst:IsA("GuiObject") then
				local existing = inst:FindFirstChildOfClass("UIStroke")
				local stroke = existing or Instance.new("UIStroke")
				stroke.Name = "Stroke"
				if type(args.stroke) == "number" then
					stroke.Thickness = args.stroke
				elseif type(args.stroke) == "table" then
					stroke.Thickness = tonumber(args.stroke.thickness) or 1
					if args.stroke.color then
						stroke.Color = parseValueString(args.stroke.color)
					end
					if args.stroke.transparency then
						stroke.Transparency = tonumber(args.stroke.transparency) or 0
					end
				end
				stroke.Parent = inst
				if not existing then table.insert(created, "UIStroke") end
			end

			-- Aspect ratio constraint
			if args.aspect_ratio and inst:IsA("GuiObject") then
				local existing = inst:FindFirstChildOfClass("UIAspectRatioConstraint")
				local ar = existing or Instance.new("UIAspectRatioConstraint")
				ar.Name = "AspectRatio"
				ar.AspectRatio = tonumber(args.aspect_ratio) or 1
				ar.Parent = inst
				if not existing then table.insert(created, "UIAspectRatioConstraint") end
			end
		end)

		if applyErr then return "ERROR applying layout: " .. applyErr end

		if #created == 0 then
			return "✅ Updated existing layout helpers on " .. args.path
		end
		return "✅ Applied layout to " .. args.path .. " — created: " .. table.concat(created, ", ")
		-- ── Script reads ─────────────────────────────────────────
	elseif name == "get_script_source" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if not inst:IsA("LuaSourceContainer") then
			return "ERROR: " .. args.path .. " is a " .. inst.ClassName .. ", not a script"
		end
		local ok, src = pcall(function() return inst.Source end)
		if not ok then return "ERROR reading source: " .. tostring(src) end
		if #src > CONFIG.MAX_SCRIPT_CHARS then
			src = src:sub(1, CONFIG.MAX_SCRIPT_CHARS) ..
				"\n-- [truncated at " .. CONFIG.MAX_SCRIPT_CHARS .. " chars; use read_script_section for more]"
		end
		return "Source of " .. args.path .. " (" .. inst.ClassName .. "):\n```lua\n" .. src .. "\n```"

	elseif name == "read_script_section" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if not inst:IsA("LuaSourceContainer") then
			return "ERROR: " .. args.path .. " is not a script"
		end
		local ok, src = pcall(function() return inst.Source end)
		if not ok then return "ERROR reading source: " .. tostring(src) end

		local startLine = tonumber(args.start_line) or 1
		local endLine   = tonumber(args.end_line) or math.huge
		if startLine < 1 then startLine = 1 end
		if endLine < startLine then
			return "ERROR: end_line (" .. endLine .. ") must be >= start_line (" .. startLine .. ")"
		end

		local lines, lineNo = {}, 0
		for line in (src .. "\n"):gmatch("([^\n]*)\n") do
			lineNo = lineNo + 1
			if lineNo >= startLine and lineNo <= endLine then
				table.insert(lines, string.format("%4d | %s", lineNo, line))
			end
			if lineNo > endLine then break end
		end
		if #lines == 0 then
			return "ERROR: no lines in range " .. startLine .. "-" .. endLine ..
				" (script has " .. lineNo .. " lines total)"
		end
		return string.format(
			"Lines %d-%d of %s (script has %d total):\n```lua\n%s\n```",
			startLine, math.min(endLine, lineNo), args.path, lineNo, table.concat(lines, "\n")
		)

		-- ── Script writes ────────────────────────────────────────
	elseif name == "set_script_source" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if not inst:IsA("LuaSourceContainer") then
			return "ERROR: " .. args.path .. " is not a script"
		end

		local newSource = args.source
		if args.source_b64 then
			local ok, decoded = pcall(function() return HttpService:Base64Decode(args.source_b64) end)
			if not ok then return "ERROR: source_b64 failed to decode: " .. tostring(decoded) end
			newSource = decoded
		end
		if type(newSource) ~= "string" then
			return "ERROR: must provide either 'source' (string) or 'source_b64' (base64 string)"
		end

		if not args.skip_validation then
			local ok, parseErr = validateLuauSyntax(newSource)
			if not ok then
				return "ERROR: syntax error before write — " .. tostring(parseErr) ..
					"\nNo changes made. Fix the syntax and retry."
			end
		end

		local _, writeErr = withWaypoint("AI edit " .. inst.Name, function()
			inst.Source = newSource
		end)
		if writeErr then return "ERROR writing source: " .. writeErr end
		return "✅ Source updated: " .. args.path .. " (" .. #newSource .. " chars, Ctrl+Z to revert)"

	elseif name == "patch_script" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if not inst:IsA("LuaSourceContainer") then
			return "ERROR: " .. args.path .. " is not a script"
		end

		local edits = args.edits
		if type(edits) ~= "table" or #edits == 0 then
			return "ERROR: 'edits' must be a non-empty array of {find, replace} objects"
		end

		-- Decode any base64-encoded find/replace into a fresh edits array so we
		-- don't mutate the caller's args table (matters for any future logger or
		-- replay system that wants the original payload).
		local decodedEdits = {}
		for i, edit in ipairs(edits) do
			if type(edit) ~= "table" then
				return "ERROR: edit #" .. i .. " is not an object"
			end
			local copy = { find = edit.find, replace = edit.replace }
			if edit.find_b64 then
				local ok, decoded = pcall(function() return HttpService:Base64Decode(edit.find_b64) end)
				if not ok then
					return "ERROR: edit #" .. i .. " find_b64 decode failed: " .. tostring(decoded)
				end
				copy.find = decoded
			end
			if edit.replace_b64 then
				local ok, decoded = pcall(function() return HttpService:Base64Decode(edit.replace_b64) end)
				if not ok then
					return "ERROR: edit #" .. i .. " replace_b64 decode failed: " .. tostring(decoded)
				end
				copy.replace = decoded
			end
			decodedEdits[i] = copy
		end
		edits = decodedEdits

		local readOk, originalSource = pcall(function() return inst.Source end)
		if not readOk then return "ERROR reading source: " .. tostring(originalSource) end

		local workingSource, applied = originalSource, {}

		for i, edit in ipairs(edits) do
			if type(edit) ~= "table" or type(edit.find) ~= "string" or type(edit.replace) ~= "string" then
				return "ERROR: edit #" .. i ..
					" must have string 'find' and 'replace' (or 'find_b64'/'replace_b64')"
			end
			if edit.find == "" then
				return "ERROR: edit #" .. i .. " has empty 'find' — refusing to match"
			end

			-- Bug 2: explicit plain-text flag, never accidentally interpret as Lua pattern
			local count, searchStart, firstPos = 0, 1, nil
			while true do
				local s, e = workingSource:find(edit.find, searchStart, true)  -- true = plain
				if not s then break end
				count = count + 1
				if count == 1 then firstPos = s end
				searchStart = e + 1
				if count > 1 then break end
			end

			-- Bug 3: better diagnostics — distinguish "never matched" from "matched original but not after prior edits"
			if count == 0 then
				local matchedOriginal = originalSource:find(edit.find, 1, true) ~= nil
				if matchedOriginal and i > 1 then
					return "ERROR: edit #" .. i ..
						" — 'find' matched the ORIGINAL file but not after edit #" .. (i - 1) ..
						" was applied. Your edits may overlap. Try reordering, or combining them into a single edit.\n" ..
						"First 200 chars of find:\n" .. edit.find:sub(1, 200)
				else
					return "ERROR: edit #" .. i ..
						" — 'find' string not found anywhere in the script.\n" ..
						"Check whitespace/indentation matches the file exactly. Consider read_script_section first.\n" ..
						"First 200 chars of find:\n" .. edit.find:sub(1, 200)
				end
			end
			if count > 1 then
				return "ERROR: edit #" .. i ..
					" — 'find' matches multiple locations. " ..
					"Include more surrounding context to make it unique."
			end

			local before = workingSource:sub(1, firstPos - 1)
			local after  = workingSource:sub(firstPos + #edit.find)
			workingSource = before .. edit.replace .. after
			table.insert(applied, {
				index = i,
				delta = #edit.replace - #edit.find,
				find = edit.find,
				replace = edit.replace,
			})
		end

		if not args.skip_validation then
			local ok, parseErr = validateLuauSyntax(workingSource)
			if not ok then
				return "ERROR: patch would produce syntactically invalid script — " .. tostring(parseErr) ..
					"\nNo changes made. Review your edits."
			end
		end

		-- Bug 5: verify the write actually happened by reading back
		local writeSucceeded = false
		local _, writeErr = withWaypoint("AI patch " .. inst.Name, function()
			inst.Source = workingSource
			-- Read back to confirm the assignment took effect
			if inst.Source == workingSource then
				writeSucceeded = true
			end
		end)
		if writeErr then return "ERROR writing patched source: " .. writeErr end
		if not writeSucceeded then
			return "ERROR: write appeared to succeed but source did not change. " ..
				"Check that the script is not locked or read-only."
		end

		-- Design fix: return a compact diff so the AI can verify without re-reading
		local netDelta = 0
		for _, a in ipairs(applied) do netDelta = netDelta + a.delta end

		local diffPreview = summarizeEdits(applied)
		return string.format(
			"✅ Patched %s — %d edit(s), net %s%d chars (Ctrl+Z to revert)\n\nChanges:\n%s",
			args.path, #applied, netDelta >= 0 and "+" or "", netDelta, diffPreview
		)

	elseif name == "create_script" then
		local parent, err = resolvePath(args.parent)
		if not parent then return "ERROR: " .. err end
		local scriptType = args.type or "Script"
		if scriptType ~= "Script" and scriptType ~= "LocalScript" and scriptType ~= "ModuleScript" then
			return "ERROR: type must be Script | LocalScript | ModuleScript"
		end
		if args.source and not args.skip_validation then
			local ok, parseErr = validateLuauSyntax(args.source)
			if not ok then
				return "ERROR: syntax error in initial source — " .. tostring(parseErr) ..
					"\nNo script created."
			end
		end
		local result, createErr = withWaypoint("AI create script", function()
			local s = Instance.new(scriptType)
			s.Name = args.name or "AIScript"
			s.Source = args.source or ""
			s.Parent = parent
			Selection:Set({s})
			return s
		end)
		if createErr then return "ERROR creating script: " .. createErr end
		return "✅ Created " .. scriptType .. " '" .. result.Name .. "' in " .. args.parent
		-- ── Instance reads ───────────────────────────────────────
	elseif name == "list_children" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local ok, children = pcall(function() return inst:GetChildren() end)
		if not ok then return "ERROR: " .. tostring(children) end
		if #children == 0 then return args.path .. " has no children." end
		local lines = { "Children of " .. args.path .. " (" .. #children .. "):" }
		for _, child in ipairs(children) do
			table.insert(lines, "  [" .. child.ClassName .. "] " .. child.Name)
		end
		return table.concat(lines, "\n")

	elseif name == "get_property" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local ok, val = pcall(function() return inst[args.property] end)
		if not ok then return "ERROR: " .. tostring(val) end
		return args.path .. "." .. args.property .. " = " .. valueToString(val)

	elseif name == "get_all_properties" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local COMMON = {
			"Name","ClassName","Archivable","Parent",
			"Position","Size","Orientation","CFrame","Anchored","CanCollide",
			"Transparency","BrickColor","Color","Material","Reflectance","CastShadow",
			"Velocity","AssemblyLinearVelocity","AssemblyAngularVelocity",
			"Disabled","LinkedSource",
			"Text","Font","TextSize","TextColor3","BackgroundColor3","BackgroundTransparency",
			"Visible","Active","ZIndex","SizeConstraint",
			"Volume","SoundId","Looped","Playing","TimePosition",
			"MaxHealth","Health","WalkSpeed","JumpPower","JumpHeight",
			"Value","PrimaryPart","Brightness","Range","Angle",
			"Enabled","Rate","Speed","ResetOnSpawn","Adornee","AlwaysOnTop",
		}
		local lines = { "Properties of [" .. inst.ClassName .. "] " .. inst:GetFullName() .. ":" }
		for _, prop in ipairs(COMMON) do
			local ok, val = pcall(function() return inst[prop] end)
			if ok and val ~= nil then
				table.insert(lines, "  " .. prop .. " = " .. valueToString(val))
			end
		end
		return table.concat(lines, "\n")

	elseif name == "find_instances" then
		local results = {}
		local function search(inst)
			local classMatch = not args.class or inst.ClassName == args.class
			local nameMatch
			if not args.name then
				nameMatch = true
			elseif args.partial then
				nameMatch = inst.Name:lower():find(args.name:lower(), 1, true) ~= nil
			else
				nameMatch = inst.Name == args.name
			end
			if classMatch and nameMatch then
				table.insert(results, "[" .. inst.ClassName .. "] " .. inst:GetFullName())
				if #results >= 100 then return end
			end
			local ok, children = pcall(function() return inst:GetChildren() end)
			if ok then
				for _, child in ipairs(children) do
					search(child)
					if #results >= 100 then return end
				end
			end
		end
		search(game)
		if #results == 0 then return "No instances found." end
		local lines = { "Found " .. #results .. " instance(s):" }
		for _, r in ipairs(results) do table.insert(lines, "  " .. r) end
		if #results == 100 then table.insert(lines, "  (limit 100 — refine search)") end
		return table.concat(lines, "\n")

	elseif name == "get_selection" then
		local sel = Selection:Get()
		if #sel == 0 then return "Nothing is currently selected." end
		local lines = { "Currently selected (" .. #sel .. "):" }
		for _, inst in ipairs(sel) do
			table.insert(lines, "  [" .. inst.ClassName .. "] " .. inst:GetFullName())
		end
		return table.concat(lines, "\n")

	elseif name == "set_selection" then
		local toSelect, errors = {}, {}
		for _, path in ipairs(args.paths or {}) do
			local inst, err = resolvePath(path)
			if inst then table.insert(toSelect, inst)
			else table.insert(errors, path .. ": " .. err) end
		end
		Selection:Set(toSelect)
		local msg = "✅ Selected " .. #toSelect .. " instance(s)."
		if #errors > 0 then msg = msg .. "\nErrors: " .. table.concat(errors, "; ") end
		return msg
		-- ── Instance mutations ───────────────────────────────────
	elseif name == "set_property" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		-- ── Narrow high-risk gate: writing Source or toggling Disabled on a script
		-- These are equivalent to set_script_source / disabling a running script,
		-- so they get the same confirmation treatment.
		if inst:IsA("LuaSourceContainer")
			and (args.property == "Source" or args.property == "Disabled")
			and not args.dry_run then
			local gateName = "set_property:" .. args.property
			local verdict = confirmAction(gateName, args)
			if verdict == "deny" then
				return "🛑 Denied by user."
			elseif verdict == "dryrun" then
				args.dry_run = true
				-- Falls through; the universal dry-run guard handled this at the
				-- top of executeTool, but we already passed it. Format the preview
				-- inline and return.
				local preview = HttpService:JSONEncode(args)
				if #preview > 400 then preview = preview:sub(1, 400) .. "..." end
				return "DRY RUN — would call set_property(" .. preview .. ")\nNo changes made."
			end
		end

		local val = args.value
		local valType = args.type
		if type(val) == "table" then
			if valType == "Color3" then
				val = Color3.fromRGB(val[1] or 0, val[2] or 0, val[3] or 0)
			elseif valType == "BrickColor" then
				val = BrickColor.new(val[1] or 0, val[2] or 0, val[3] or 0)
			elseif valType == "UDim2" and #val >= 4 then
				val = UDim2.new(val[1], val[2], val[3], val[4])
			elseif valType == "Vector2" and #val == 2 then
				val = Vector2.new(val[1], val[2])
			else
				val = Vector3.new(val[1] or 0, val[2] or 0, val[3] or 0)
			end
		elseif valType == "Enum" and type(val) == "string" then
			local ok, enumVal = pcall(function()
				local mat, item = val:match("^Enum%.(%w+)%.(%w+)$")
				if not item then mat, item = val:match("^(%w+)%.(%w+)$") end
				return Enum[mat][item]
			end)
			if ok then val = enumVal end
		end
		local _, setErr = withWaypoint("AI set " .. tostring(args.property), function()
			inst[args.property] = val
		end)
		if setErr then return "ERROR setting property: " .. setErr end
		return "✅ Set " .. args.path .. "." .. args.property

	elseif name == "create_instance" then
		local parent, err = resolvePath(args.parent)
		if not parent then return "ERROR: " .. err end
		local result, createErr = withWaypoint("AI create " .. tostring(args.class), function()
			local inst = Instance.new(args.class)
			inst.Name = args.name or args.class
			if args.properties then
				for prop, val in pairs(args.properties) do
					if type(val) == "table" and #val == 3 then
						if prop:find("Color3") then
							val = Color3.fromRGB(val[1], val[2], val[3])
						else
							val = Vector3.new(val[1], val[2], val[3])
						end
					end
					pcall(function() inst[prop] = val end)
				end
			end
			inst.Parent = parent
			Selection:Set({inst})
			return inst
		end)
		if createErr then return "ERROR creating instance: " .. createErr end
		return "✅ Created " .. args.class .. " '" .. result.Name .. "' in " .. args.parent

	elseif name == "rename_instance" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local old = inst.Name
		local _, renErr = withWaypoint("AI rename", function()
			inst.Name = args.name
		end)
		if renErr then return "ERROR renaming: " .. renErr end
		return "✅ Renamed '" .. old .. "' → '" .. args.name .. "'"

	elseif name == "move_instance" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local newParent, err2 = resolvePath(args.new_parent)
		if not newParent then return "ERROR resolving new_parent: " .. err2 end
		local old = inst:GetFullName()
		local _, mvErr = withWaypoint("AI move", function()
			inst.Parent = newParent
		end)
		if mvErr then return "ERROR moving: " .. mvErr end
		return "✅ Moved " .. old .. " → " .. newParent:GetFullName() .. "." .. inst.Name

	elseif name == "clone_instance" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end

		local parentPath = args.new_parent
		if not parentPath then
			if not inst.Parent then
				return "ERROR: " .. args.path .. " has no parent and no new_parent was specified"
			end
			parentPath = inst.Parent:GetFullName():gsub("^game%.", "")
		end

		local newParent, err2 = resolvePath(parentPath)
		if not newParent then return "ERROR resolving new_parent: " .. err2 end

		local _, cloneErr = withWaypoint("AI clone", function()
			local c = inst:Clone()
			if args.name then c.Name = args.name end
			c.Parent = newParent
			Selection:Set({c})
		end)
		if cloneErr then return "ERROR cloning: " .. cloneErr end
		return "✅ Cloned " .. args.path

	elseif name == "delete_instance" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local fullName = inst:GetFullName()
		local _, delErr = withWaypoint("AI delete " .. inst.Name, function()
			inst:Destroy()
		end)
		if delErr then return "ERROR deleting: " .. delErr end
		return "✅ Deleted: " .. fullName .. " (Ctrl+Z to revert)"
		-- ── Execution ────────────────────────────────────────────
	elseif name == "run_script" then
		local code = args.code or ""
		if not loadstring then return "ERROR: loadstring is disabled in this context" end
		local fn, loadErr = loadstring("return (function()\n" .. code .. "\nend)()")
		if not fn then return "ERROR compiling: " .. tostring(loadErr) end
		local ok, result = pcall(fn)
		if not ok then return "ERROR running: " .. tostring(result) end
		if result == nil then return "Script ran (no return value)." end
		return "Result: " .. tostring(result)

	elseif name == "insert_model" then
		local parent, err = resolvePath(args.parent or "Workspace")
		if not parent then return "ERROR: " .. err end
		local assetId = tonumber(args.asset_id)
		if not assetId then return "ERROR: asset_id must be a number" end
		local result, insErr = withWaypoint("AI insert model", function()
			local model = game:GetService("InsertService"):LoadAsset(assetId)
			model.Parent = parent
			Selection:Set({model})
			return model
		end)
		if insErr then return "ERROR inserting model: " .. insErr end
		return "✅ Inserted asset " .. assetId .. " into " .. (args.parent or "Workspace")

	elseif name == "get_datastore_keys" then
		local ok, keys, hasMore = pcall(function()
			local store = game:GetService("DataStoreService"):GetDataStore(args.store_name)
			local limit = math.min(tonumber(args.limit) or 20, 100)
			local pages = store:ListKeysAsync()
			local collected = {}
			local page = pages:GetCurrentPage()
			for _, k in ipairs(page) do
				table.insert(collected, k.KeyName)
				if #collected >= limit then break end
			end
			-- More results exist if we filled the limit and the page had more,
			-- or if the pager itself isn't finished
			local more = (#collected >= limit and #page > #collected) or not pages.IsFinished
			return collected, more
		end)
		if not ok then
			return "ERROR accessing DataStore: " .. tostring(keys) ..
				"\n(Requires API access enabled)"
		end
		if #keys == 0 then return "DataStore '" .. args.store_name .. "' has no keys." end
		local out = "Keys in '" .. args.store_name .. "':\n  " .. table.concat(keys, "\n  ")
		if hasMore then
			out = out .. "\n\n(More keys exist beyond this page — results truncated. Increase 'limit' up to 100 or page through manually.)"
		end
		return out
		-- ── Debugging ────────────────────────────────────────────
	elseif name == "get_recent_output" then
		local limit = tonumber(args.limit) or 50
		local filter = args.filter  -- optional: "error" | "warn" | "info"

		local recent = readOutputRing(limit * 2)
		local out = {}
		for _, line in ipairs(recent) do
			local include = true
			if filter == "error" then
				include = line:find("%[ERROR%]") ~= nil
			elseif filter == "warn" then
				include = line:find("%[WARN%]") ~= nil
			elseif filter == "info" then
				include = line:find("%[INFO%]") ~= nil
			end
			if include then
				table.insert(out, line)
				if #out >= limit then break end
			end
		end
		if #out == 0 then
			return "No recent output" .. (filter and (" matching filter '" .. filter .. "'") or "") .. "."
		end
		return "Last " .. #out .. " output line(s):\n" .. table.concat(out, "\n")

	elseif name == "check_ui_layout" then
		-- Renders the target UI off-screen (a temp ScreenGui in CoreGui), waits a
		-- couple frames for the layout engine to compute AbsoluteSize/Position,
		-- then reports the real geometry and flags common breakages. This is the
		-- only reliable way to read rendered geometry: GuiObjects sitting in
		-- StarterGui are NOT laid out in edit mode (they only render once cloned
		-- into a player's PlayerGui at runtime), so AbsoluteSize is 0 there.
		local target, terr = resolvePath(args.path)
		if not target then return "ERROR resolving path: " .. terr end
		if not (target:IsA("GuiObject") or target:IsA("ScreenGui") or target:IsA("GuiBase2d")) then
			return "ERROR: '" .. target:GetFullName() .. "' is a " .. target.ClassName ..
				", not a UI object. Point this at a ScreenGui or a Frame/Label/Button etc."
		end

		local CoreGui = game:GetService("CoreGui")
		local RunService = game:GetService("RunService")
		local holder = Instance.new("ScreenGui")
		holder.Name = "UnlimitedAI_LayoutProbe"
		holder.ResetOnSpawn = false
		holder.DisplayOrder = -1000           -- behind everything; we destroy it anyway
		holder.IgnoreGuiInset = true

		-- Clone what we're measuring. For a ScreenGui, measure its children inside
		-- our holder; for a plain GuiObject, clone it directly. We map cloned
		-- instances back to their originals by full-path so the report names the
		-- REAL objects, not the temporary clones.
		local clones = {}
		local originalRootPath = target:GetFullName()
		if target:IsA("ScreenGui") or target:IsA("GuiBase2d") and not target:IsA("GuiObject") then
			for _, child in ipairs(target:GetChildren()) do
				if child:IsA("GuiObject") or child:IsA("UIBase") then
					local c = child:Clone()
					c.Parent = holder
					clones[c] = child
				end
			end
		else
			local c = target:Clone()
			c.Parent = holder
			clones[c] = target
		end

		holder.Parent = CoreGui
		-- Let the layout engine run. A couple of Heartbeats is enough for
		-- UIListLayout/UIGridLayout/constraints to settle.
		pcall(function() RunService.Heartbeat:Wait(); RunService.Heartbeat:Wait() end)

		local viewport = holder.AbsoluteSize  -- approx screen size used for off-screen checks
		local issues, lines = {}, {}
		local counted = 0

		local function relName(cloneObj)
			-- Build a readable name relative to the probed root.
			local orig = clones[cloneObj]
			if orig then return orig:GetFullName() end
			return cloneObj.Name
		end

		local function walk(obj, originName)
			for _, child in ipairs(obj:GetChildren()) do
				if child:IsA("GuiObject") then
					counted = counted + 1
					local sz = child.AbsoluteSize
					local pos = child.AbsolutePosition
					local nm = originName .. "." .. child.Name
					table.insert(lines, string.format("  %s — size %dx%d at (%d, %d)%s",
						nm, sz.X, sz.Y, pos.X, pos.Y,
						child.Visible and "" or " [Visible=false]"))

					-- Flag breakages.
					if child.Visible then
						if sz.X < 1 or sz.Y < 1 then
							table.insert(issues, "COLLAPSED: " .. nm .. " has ~zero size (" ..
								sz.X .. "x" .. sz.Y .. ") — check its Size; a Scale of 0, " ..
								"an empty UIListLayout parent, or AutomaticSize not set?")
						end
						if viewport.X > 0 and viewport.Y > 0 then
							if pos.X + sz.X < 0 or pos.Y + sz.Y < 0
								or pos.X > viewport.X or pos.Y > viewport.Y then
								table.insert(issues, "OFF-SCREEN: " .. nm .. " sits outside the " ..
									viewport.X .. "x" .. viewport.Y .. " view at (" .. pos.X ..
									", " .. pos.Y .. ") — check AnchorPoint/Position.")
							end
						end
						-- Child overflowing its parent's box (only when parent is a GuiObject).
						local par = child.Parent
						if par and par:IsA("GuiObject") and not (par:FindFirstChildOfClass("UIListLayout")
							or par:FindFirstChildOfClass("UIGridLayout")) then
							local psz, ppos = par.AbsoluteSize, par.AbsolutePosition
							if psz.X > 0 and (pos.X + sz.X > ppos.X + psz.X + 2 or pos.Y + sz.Y > ppos.Y + psz.Y + 2
								or pos.X < ppos.X - 2 or pos.Y < ppos.Y - 2) then
								table.insert(issues, "OVERFLOW: " .. nm .. " extends past its parent " ..
									par.Name .. "'s bounds — turn on ClipsDescendants or resize it.")
							end
						end
					end
					walk(child, nm)
				end
			end
		end

		for cloneObj, orig in pairs(clones) do
			if cloneObj:IsA("GuiObject") then
				local rootName = orig:GetFullName()
				local sz, pos = cloneObj.AbsoluteSize, cloneObj.AbsolutePosition
				table.insert(lines, string.format("  %s — size %dx%d at (%d, %d)%s",
					rootName, sz.X, sz.Y, pos.X, pos.Y, cloneObj.Visible and "" or " [Visible=false]"))
				if cloneObj.Visible and (sz.X < 1 or sz.Y < 1) then
					table.insert(issues, "COLLAPSED: " .. rootName .. " has ~zero size.")
				end
				counted = counted + 1
				walk(cloneObj, rootName)
			end
		end

		pcall(function() holder:Destroy() end)  -- always clean up the probe

		if counted == 0 then
			return "No GuiObjects found under " .. originalRootPath .. " to measure."
		end
		local report = "Rendered " .. counted .. " GuiObject(s) under " .. originalRootPath ..
			" (measured off-screen at ~" .. viewport.X .. "x" .. viewport.Y .. "):\n" ..
			table.concat(lines, "\n")
		if #issues == 0 then
			return report .. "\n\n✅ No layout problems detected (no collapsed, off-screen, or overflowing elements)."
		end
		return report .. "\n\n⚠️ " .. #issues .. " potential issue(s):\n• " .. table.concat(issues, "\n• ") ..
			"\n\nNote: measured at one screen size — also sanity-check on a phone aspect ratio if it's mobile UI."

	elseif name == "apply_theme" then
		-- Walks a UI subtree and applies a consistent palette/font/corner-radius
		-- to every relevant GuiObject in ONE pass — backgrounds, button accents,
		-- text colors, font, rounded corners. Use a named preset or override any
		-- piece. This is an ACTIVE bulk edit (one undo waypoint), not advisory.
		local target, terr = resolvePath(args.path)
		if not target then return "ERROR resolving path: " .. terr end

		local PRESETS = {
			dark   = { background = {30, 32, 42}, accent = {70, 110, 220}, text = {235, 238, 245}, font = "GothamMedium", corner = 8 },
			light  = { background = {245, 246, 250}, accent = {80, 130, 240}, text = {30, 32, 40}, font = "GothamMedium", corner = 8 },
			neon   = { background = {18, 18, 28}, accent = {0, 230, 180}, text = {230, 255, 250}, font = "GothamBold", corner = 6 },
			pastel = { background = {250, 244, 250}, accent = {180, 150, 230}, text = {70, 60, 80}, font = "FredokaOne", corner = 12 },
		}

		local themeName = type(args.theme) == "string" and args.theme:lower() or "dark"
		local base = PRESETS[themeName] or PRESETS.dark
		-- Per-field overrides (each [r,g,b], a font name string, or a corner px).
		local function rgb(arg, fallback)
			if type(arg) == "table" and #arg >= 3 then return Color3.fromRGB(arg[1], arg[2], arg[3]) end
			return Color3.fromRGB(fallback[1], fallback[2], fallback[3])
		end
		local bgColor   = rgb(args.background, base.background)
		local accentCol = rgb(args.accent, base.accent)
		local textCol   = rgb(args.text, base.text)
		local cornerPx  = tonumber(args.corner_radius) or base.corner
		local fontName  = (type(args.font) == "string" and args.font ~= "") and args.font or base.font
		local fontEnum
		pcall(function() fontEnum = Enum.Font[fontName] end)

		local function ensureCorner(obj)
			if cornerPx <= 0 then return end
			local c = obj:FindFirstChildOfClass("UICorner")
			if not c then c = Instance.new("UICorner"); c.Parent = obj end
			c.CornerRadius = UDim.new(0, cornerPx)
		end

		local touched = 0
		local result, cErr = withWaypoint("AI apply theme '" .. themeName .. "'", function()
			local function style(obj)
				for _, child in ipairs(obj:GetChildren()) do
					if child:IsA("GuiObject") then
						local isButton = child:IsA("TextButton") or child:IsA("ImageButton")
						local isText   = child:IsA("TextLabel") or child:IsA("TextButton") or child:IsA("TextBox")
						-- Background: recolor only opaque surfaces (skip pure containers
						-- and image icons that are meant to be transparent).
						if child.BackgroundTransparency < 1 then
							child.BackgroundColor3 = isButton and accentCol or bgColor
							ensureCorner(child)
							touched = touched + 1
						end
						-- Text color + font on anything that shows text.
						if isText then
							child.TextColor3 = textCol
							if fontEnum then child.Font = fontEnum end
							touched = touched + 1
						end
					end
					style(child)
				end
			end
			-- Style the target itself if it's a GuiObject, then its descendants.
			if target:IsA("GuiObject") then
				if target.BackgroundTransparency < 1 then
					target.BackgroundColor3 = bgColor
					ensureCorner(target)
					touched = touched + 1
				end
				if target:IsA("TextLabel") or target:IsA("TextButton") or target:IsA("TextBox") then
					target.TextColor3 = textCol
					if fontEnum then target.Font = fontEnum end
				end
			end
			style(target)
			return true
		end)
		if cErr then return "ERROR applying theme: " .. cErr end
		if touched == 0 then
			return "No styleable GuiObjects found under " .. target:GetFullName() .. "."
		end
		return "✅ Applied theme '" .. themeName .. "' to " .. touched .. " element(s) under " ..
			target:GetFullName() .. " — backgrounds, button accents, text color, font (" .. fontName ..
			"), and " .. cornerPx .. "px corners. Run check_ui_layout if you want to confirm nothing shifted."

		-- ── Code search ──────────────────────────────────────────────────────
	elseif name == "search_scripts" then
		local query = args.query or args.pattern
		if type(query) ~= "string" or query == "" then
			return "ERROR: 'query' (search string) is required"
		end
		local useRegex = args.regex == true
		local ci = args.case_insensitive == true
		local maxResults = math.min(tonumber(args.max_results) or 60, 300)

		local root = game
		if args.path and args.path ~= "" then
			local r, perr = resolvePath(args.path)
			if not r then return "ERROR: " .. perr end
			root = r
		end

		local needle = ci and query:lower() or query
		local results, scanned, truncated = {}, 0, false

		local function scan(inst)
			if truncated then return end
			if inst:IsA("LuaSourceContainer") then
				scanned = scanned + 1
				local ok, src = pcall(function() return inst.Source end)
				if ok and type(src) == "string" then
					local lineNo = 0
					for line in (src .. "\n"):gmatch("([^\n]*)\n") do
						lineNo = lineNo + 1
						local hay = ci and line:lower() or line
						local hit
						if useRegex then
							local okm, res = pcall(function() return hay:find(needle) ~= nil end)
							hit = okm and res
						else
							hit = hay:find(needle, 1, true) ~= nil
						end
						if hit then
							local snippet = line:match("^%s*(.-)%s*$")
							if #snippet > 160 then snippet = snippet:sub(1, 160) .. "..." end
							table.insert(results, string.format("%s:%d: %s",
								inst:GetFullName(), lineNo, snippet))
							if #results >= maxResults then truncated = true; return end
						end
					end
				end
			end
			local ok, children = pcall(function() return inst:GetChildren() end)
			if ok then
				for _, c in ipairs(children) do
					scan(c)
					if truncated then return end
				end
			end
		end
		scan(root)

		if #results == 0 then
			return "No matches for '" .. query .. "' in " .. scanned .. " script(s)."
		end
		local header = string.format("Found %d match(es) for '%s' across %d script(s):",
			#results, query, scanned)
		local body = table.concat(results, "\n")
		if truncated then
			body = body .. "\n(... result limit " .. maxResults .. " reached — refine your query)"
		end
		return header .. "\n" .. body

	elseif name == "get_script_diagnostics" then
		local inst, derr = resolvePath(args.path)
		if not inst then return "ERROR: " .. derr end
		if not inst:IsA("LuaSourceContainer") then
			return "ERROR: " .. args.path .. " is not a script"
		end
		local ok, src = pcall(function() return inst.Source end)
		if not ok then return "ERROR reading source: " .. tostring(src) end

		local findings = {}

		-- 1. Syntax / compile check (first parser error with location)
		if loadstring then
			local fn, lerr = loadstring(src)
			if not fn and lerr then
				findings[#findings + 1] = "SYNTAX: " .. tostring(lerr)
			end
		end

		-- 2. Lightweight heuristic lint (best-effort, line-scoped)
		local lineNo = 0
		for line in (src .. "\n"):gmatch("([^\n]*)\n") do
			lineNo = lineNo + 1
			local code = line:gsub("%-%-.*$", "")  -- drop trailing line comment
			local function flag(msg)
				findings[#findings + 1] = string.format("L%d: %s", lineNo, msg)
			end
			if code:find("[^%w_%.]wait%s*%(") or code:match("^%s*wait%s*%(") then
				flag("uses wait() — prefer task.wait()")
			end
			if code:find("[^%w_%.]spawn%s*%(") or code:match("^%s*spawn%s*%(") then
				flag("uses spawn() — prefer task.spawn()")
			end
			if code:find("[^%w_%.]delay%s*%(") or code:match("^%s*delay%s*%(") then
				flag("uses delay() — prefer task.delay()")
			end
			if code:find(":connect%s*%(") then
				flag("lowercase :connect — use :Connect")
			end
		end

		if #findings == 0 then
			return "✅ No syntax errors or common issues found in " .. args.path ..
				"\n(Syntax + heuristic check — not full Studio Script Analysis.)"
		end
		local out = { "Diagnostics for " .. args.path .. " (" .. #findings .. "):" }
		for i = 1, math.min(#findings, 40) do
			out[#out + 1] = "  • " .. findings[i]
		end
		if #findings > 40 then out[#out + 1] = "  ...and " .. (#findings - 40) .. " more." end
		out[#out + 1] = "(Syntax + heuristic check — not full Studio Script Analysis.)"
		return table.concat(out, "\n")
		-- ── Attributes ─────────────────────────────────────────
	elseif name == "get_attribute" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local ok, val = pcall(function() return inst:GetAttribute(args.name) end)
		if not ok then return "ERROR: " .. tostring(val) end
		if val == nil then return args.path .. " has no attribute '" .. tostring(args.name) .. "'." end
		return args.path .. "@" .. tostring(args.name) .. " = " .. valueToString(val)

	elseif name == "get_all_attributes" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local ok, attrs = pcall(function() return inst:GetAttributes() end)
		if not ok then return "ERROR: " .. tostring(attrs) end
		local keys = {}
		for k in pairs(attrs) do keys[#keys + 1] = k end
		table.sort(keys)
		if #keys == 0 then return args.path .. " has no attributes." end
		local out = { "Attributes of " .. args.path .. " (" .. #keys .. "):" }
		for _, k in ipairs(keys) do out[#out + 1] = "  " .. k .. " = " .. valueToString(attrs[k]) end
		return table.concat(out, "\n")

	elseif name == "set_attribute" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if type(args.name) ~= "string" or args.name == "" then
			return "ERROR: 'name' (attribute name) is required"
		end
		local value = parseValueString(args.value)
		local _, setErr = withWaypoint("AI set attribute " .. args.name, function()
			inst:SetAttribute(args.name, value)
		end)
		if setErr then return "ERROR setting attribute: " .. setErr end
		return "✅ Set " .. args.path .. "@" .. args.name .. " = " .. valueToString(inst:GetAttribute(args.name))

		-- ── CollectionService tags ─────────────────────────────
	elseif name == "get_tags" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local CollectionService = game:GetService("CollectionService")
		local tags = CollectionService:GetTags(inst)
		if #tags == 0 then return args.path .. " has no tags." end
		return "Tags on " .. args.path .. " (" .. #tags .. "):\n  " .. table.concat(tags, "\n  ")

	elseif name == "add_tag" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if type(args.tag) ~= "string" or args.tag == "" then return "ERROR: 'tag' is required" end
		local CollectionService = game:GetService("CollectionService")
		local _, tErr = withWaypoint("AI add tag " .. args.tag, function()
			CollectionService:AddTag(inst, args.tag)
		end)
		if tErr then return "ERROR adding tag: " .. tErr end
		return "✅ Added tag '" .. args.tag .. "' to " .. args.path

	elseif name == "remove_tag" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		if type(args.tag) ~= "string" or args.tag == "" then return "ERROR: 'tag' is required" end
		local CollectionService = game:GetService("CollectionService")
		local _, tErr = withWaypoint("AI remove tag " .. args.tag, function()
			CollectionService:RemoveTag(inst, args.tag)
		end)
		if tErr then return "ERROR removing tag: " .. tErr end
		return "✅ Removed tag '" .. args.tag .. "' from " .. args.path

	elseif name == "find_by_tag" then
		if type(args.tag) ~= "string" or args.tag == "" then return "ERROR: 'tag' is required" end
		local CollectionService = game:GetService("CollectionService")
		local ok, tagged = pcall(function() return CollectionService:GetTagged(args.tag) end)
		if not ok then return "ERROR: " .. tostring(tagged) end
		if #tagged == 0 then return "No instances tagged '" .. args.tag .. "'." end
		local out = { "Instances tagged '" .. args.tag .. "' (" .. #tagged .. "):" }
		for i, inst in ipairs(tagged) do
			out[#out + 1] = "  [" .. inst.ClassName .. "] " .. inst:GetFullName()
			if i >= 100 then out[#out + 1] = "  (limit 100 — refine)"; break end
		end
		return table.concat(out, "\n")

		-- ── Find references (symbol grep across scripts) ──────
	elseif name == "find_references" then
		local sym = args.symbol or args.name
		if type(sym) ~= "string" or sym == "" then return "ERROR: 'symbol' is required" end
		local pat = "[^%w_]" .. sym:gsub("(%W)", "%%%1") .. "[^%w_]"
		local results, scanned, truncated = {}, 0, false
		local maxResults = math.min(tonumber(args.max_results) or 80, 300)
		local function scan(inst)
			if truncated then return end
			if inst:IsA("LuaSourceContainer") then
				scanned = scanned + 1
				local ok, srcText = pcall(function() return inst.Source end)
				if ok and type(srcText) == "string" then
					local lineNo = 0
					for line in (srcText .. "\n"):gmatch("([^\n]*)\n") do
						lineNo = lineNo + 1
						if (" " .. line .. " "):find(pat) then
							local snip = line:match("^%s*(.-)%s*$")
							if #snip > 160 then snip = snip:sub(1, 160) .. "..." end
							results[#results + 1] = string.format("%s:%d: %s", inst:GetFullName(), lineNo, snip)
							if #results >= maxResults then truncated = true; return end
						end
					end
				end
			end
			local ok, ch = pcall(function() return inst:GetChildren() end)
			if ok then for _, c in ipairs(ch) do scan(c); if truncated then return end end end
		end
		scan(game)
		if #results == 0 then return "No references to '" .. sym .. "' in " .. scanned .. " script(s)." end
		local body = table.concat(results, "\n")
		if truncated then body = body .. "\n(... limit " .. maxResults .. " reached — refine)" end
		return string.format("Found %d reference(s) to '%s' across %d script(s):\n%s", #results, sym, scanned, body)

		-- ── Runtime errors (feedback loop after a playtest/run) ──
	elseif name == "get_runtime_errors" then
		local limit = math.min(tonumber(args.limit) or 30, ERROR_LOG_MAX)
		if #errorLog == 0 then
			return "No runtime errors captured this session.\n"
				.. "(Errors are captured from the edit session's Output. Press Play/Run, "
				.. "reproduce the issue, then call this again. Studio's separate playtest "
				.. "DataModel may not always forward errors here.)"
		end
		local out, lastMsg, count = {}, nil, 0
		local function flush()
			if lastMsg then
				out[#out + 1] = (count > 1 and ("(x" .. count .. ") ") or "") .. lastMsg
			end
		end
		local startIdx = math.max(1, #errorLog - limit + 1)
		for i = startIdx, #errorLog do
			local e = errorLog[i]
			local entry = e.time .. "  " .. e.msg
			if entry == lastMsg then count = count + 1
			else flush(); lastMsg = entry; count = 1 end
		end
		flush()
		local shown = #errorLog >= limit and limit or #errorLog
		local header = "Last " .. shown .. " runtime error(s) (" .. #errorLog .. " total this session):"
		local body = header .. "\n" .. table.concat(out, "\n\n")
		if args.clear == true then
			errorLog = {}
			body = body .. "\n\n(Error log cleared.)"
		end
		return body

		-- ── Project-wide diagnostics ─────────────────────────
	elseif name == "scan_all_diagnostics" then
		local root = game
		if args.path and args.path ~= "" then
			local r, perr = resolvePath(args.path)
			if not r then return "ERROR: " .. perr end
			root = r
		end
		local report, scanned, withIssues = {}, 0, 0
		local function lint(inst)
			scanned = scanned + 1
			local ok, srcText = pcall(function() return inst.Source end)
			if not ok or type(srcText) ~= "string" then return end
			local findings = {}
			if loadstring then
				local fn, lerr = loadstring(srcText)
				if not fn and lerr then findings[#findings + 1] = "SYNTAX: " .. tostring(lerr) end
			end
			local lineNo = 0
			for line in (srcText .. "\n"):gmatch("([^\n]*)\n") do
				lineNo = lineNo + 1
				local code = line:gsub("%-%-.*$", "")
				if code:find("[^%w_%.]wait%s*%(") or code:match("^%s*wait%s*%(") then
					findings[#findings + 1] = "L" .. lineNo .. ": wait() — prefer task.wait()"
				end
				if code:find(":connect%s*%(") then
					findings[#findings + 1] = "L" .. lineNo .. ": lowercase :connect — use :Connect"
				end
			end
			if #findings > 0 then
				withIssues = withIssues + 1
				report[#report + 1] = "● " .. inst:GetFullName() .. " (" .. #findings .. "):"
				for i = 1, math.min(#findings, 8) do report[#report + 1] = "    " .. findings[i] end
				if #findings > 8 then report[#report + 1] = "    ...and " .. (#findings - 8) .. " more." end
			end
		end
		local function walk(inst)
			if inst:IsA("LuaSourceContainer") then lint(inst) end
			local ok, ch = pcall(function() return inst:GetChildren() end)
			if ok then for _, c in ipairs(ch) do walk(c) end end
		end
		walk(root)
		if withIssues == 0 then
			return "✅ Scanned " .. scanned .. " script(s) — no syntax errors or common issues found.\n(Syntax + heuristic check — not full Studio Script Analysis.)"
		end
		local head = "Diagnostics across " .. scanned .. " script(s) — " .. withIssues .. " with issues:"
		return head .. "\n" .. table.concat(report, "\n") .. "\n(Syntax + heuristic check — not full Studio Script Analysis.)"

	elseif name == "lint_project" then
		-- Project-wide sanity pass for the WIRING mistakes per-script diagnostics
		-- miss: scripts placed where they can't run, wrong script type, remotes
		-- used but never created, and require() pointing at a module that isn't
		-- there. All heuristic + high-signal; it reports literal/structural
		-- problems, not dynamic ones it can't see.
		local root = game
		if args.path and args.path ~= "" then
			local r, perr = resolvePath(args.path)
			if not r then return "ERROR: " .. perr end
			root = r
		end

		local problems = {}
		local scripts = {}
		local anyRemoteInstance = false
		local usesRemotes = false

		-- Which top-level service is this instance under?
		local function rootService(inst)
			local cur = inst
			while cur and cur.Parent and cur.Parent ~= game do cur = cur.Parent end
			return cur and cur.Name or nil
		end
		-- Containers where a LocalScript actually runs.
		local CLIENT_CONTAINERS = {
			StarterGui = true, StarterPack = true, StarterPlayer = true,
			ReplicatedFirst = true, Players = true,
		}
		-- Containers where a server Script actually runs.
		local SERVER_CONTAINERS = { Workspace = true, ServerScriptService = true, Players = true }

		local function resolveLiteralRequire(pathStr)
			-- pathStr is the inside of require(...). Only handle pure literal paths
			-- starting at game; bail (return nil = "can't tell, skip") on anything
			-- dynamic so we never false-positive on variables/indexing.
			pathStr = pathStr:gsub("%s+", "")
			if not pathStr:match("^game") then return nil end
			if pathStr:find("%[") then return nil end  -- dynamic index, skip
			local cur = game
			local rest = pathStr:gsub("^game", "")
			while rest ~= "" do
				local svc = rest:match("^:GetService%(\"([%w_]+)\"%)") or rest:match("^:GetService%('([%w_]+)'%)")
				if svc then
					local ok, s = pcall(function() return game:GetService(svc) end)
					if not ok or not s then return false, svc end
					cur = s
					rest = rest:gsub("^:GetService%(%b()%)", "")
				else
					local seg = rest:match("^%.([%w_]+)")
					if not seg then return nil end  -- unexpected syntax, skip
					local child = cur:FindFirstChild(seg)
					if not child then return false, seg end  -- missing link
					cur = child
					rest = rest:gsub("^%.[%w_]+", "")
				end
			end
			return cur  -- resolved instance
		end

		local function walk(inst)
			if inst:IsA("LuaSourceContainer") then table.insert(scripts, inst) end
			if inst:IsA("RemoteEvent") or inst:IsA("RemoteFunction")
				or inst:IsA("UnreliableRemoteEvent") then anyRemoteInstance = true end
			local ok, ch = pcall(function() return inst:GetChildren() end)
			if ok then for _, c in ipairs(ch) do walk(c) end end
		end
		walk(root == game and game or game)  -- remotes can live anywhere; always scan whole tree for them

		for _, s in ipairs(scripts) do
			local full = s:GetFullName()
			local disabled = false
			pcall(function() disabled = s.Disabled end)
			local svc = rootService(s)

			-- RunContext (newer Scripts can declare Client/Server explicitly).
			local runCtx = nil
			pcall(function() runCtx = s.RunContext end)
			local isClientByContext = runCtx == Enum.RunContext.Client
			local isServerByContext = runCtx == Enum.RunContext.Server

			local src = ""
			pcall(function() src = s.Source or "" end)

			-- 1. Placement: will this script ever run where it is?
			if not disabled and svc then
				if s:IsA("LocalScript") or isClientByContext then
					if not CLIENT_CONTAINERS[svc] then
						table.insert(problems, "WON'T RUN: " .. full .. " is a client script under " .. svc ..
							", where LocalScripts/client scripts don't run. Move it to StarterPlayerScripts, " ..
							"StarterGui, StarterPack, or ReplicatedFirst.")
					end
				elseif s:IsA("Script") and not isClientByContext then
					if not SERVER_CONTAINERS[svc] then
						table.insert(problems, "WON'T RUN: " .. full .. " is a server Script under " .. svc ..
							", where server Scripts don't run. Move it to ServerScriptService or Workspace, " ..
							"or set its RunContext to Client if it's meant for the client.")
					end
				end
			end

			-- 2. LocalPlayer in a server context.
			if s:IsA("Script") and not isClientByContext and src:find("LocalPlayer") then
				table.insert(problems, "WRONG TYPE: " .. full .. " (a server Script) references LocalPlayer, " ..
					"which is nil on the server. This likely needs to be a LocalScript / client script.")
			end

			-- 3. Remote usage (for the global has-any-remote check).
			if src:find("FireServer") or src:find("FireClient") or src:find("FireAllClients")
				or src:find("InvokeServer") or src:find("InvokeClient")
				or src:find("OnServerEvent") or src:find("OnClientEvent")
				or src:find("OnServerInvoke") or src:find("OnClientInvoke") then
				usesRemotes = true
			end

			-- 4. require() literal-path resolution.
			for inner in src:gmatch("require%s*%(([^%)]+)%)") do
				local resolved, missing = resolveLiteralRequire(inner)
				if resolved == false then
					table.insert(problems, "BAD REQUIRE: " .. full .. " requires a path whose segment '" ..
						tostring(missing) .. "' doesn't exist (" .. inner:gsub("%s+", "") .. ").")
				elseif resolved and typeof(resolved) == "Instance" and not resolved:IsA("ModuleScript") then
					table.insert(problems, "BAD REQUIRE: " .. full .. " requires " .. resolved:GetFullName() ..
						" which is a " .. resolved.ClassName .. ", not a ModuleScript.")
				end
			end
		end

		if usesRemotes and not anyRemoteInstance then
			table.insert(problems, "MISSING REMOTES: scripts call FireServer/InvokeServer/etc. but there are " ..
				"NO RemoteEvent/RemoteFunction instances anywhere in the game. Create the remotes " ..
				"(commonly in a ReplicatedStorage folder) or the calls will error at runtime.")
		end

		if #problems == 0 then
			return "✅ lint_project: scanned " .. #scripts .. " script(s) — no wiring problems found " ..
				"(placement, script type, remotes, and literal require() paths all look sound). " ..
				"This catches structural mistakes, not dynamic runtime logic."
		end
		return "lint_project found " .. #problems .. " likely problem(s) across " .. #scripts ..
			" script(s):\n• " .. table.concat(problems, "\n• ") ..
			"\n\n(Heuristic structural check — verify before assuming a false positive, e.g. a " ..
			"script intentionally disabled or a remote created at runtime.)"

	elseif name == "scaffold_game" then
		-- ADVISORY: returns a proven structural blueprint for a game genre — the
		-- folder/script layout, remotes, leaderstats/DataStore plan, build order,
		-- and what the USER must supply (models, icons, maps). It does NOT create
		-- anything; you build it with your normal tools, importing models where
		-- the blueprint calls for them. Use it so you start from a sound structure
		-- instead of improvising one.
		local genre = type(args.genre) == "string" and args.genre:lower():gsub("%s+", "") or ""

		local BLUEPRINTS = {
			tycoon = [[TYCOON blueprint (advisory)

Core loop: player claims a plot -> buys droppers -> droppers spawn cash parts ->
parts hit a collector -> cash added to leaderstats -> player buys upgrades/walls.

Structure to build:
  ServerScriptService/
    TycoonServer (Script)        -- plot assignment, purchase handling, dropper loop
    DataManager  (Script)        -- save/load cash & owned items (see DataStore plan)
  ReplicatedStorage/
    Remotes/ (Folder)
      BuyItem (RemoteEvent)      -- client asks to purchase a button's item
      ClaimPlot (RemoteEvent)
    TycoonConfig (ModuleScript)  -- item prices, dropper rates, upgrade tables
  Workspace/
    Plots/ (Folder)              -- one plot Model per player slot (USER PROVIDES the plot models)
      Plot1, Plot2, ...          -- each with: Buttons folder, Items folder, a Collector part, a SpawnLocation
  StarterGui/
    TycoonHUD (ScreenGui)        -- cash display, purchase prompts

leaderstats: a folder "leaderstats" parented to each Player on join, with a
NumberValue/IntValue "Cash". Server-authoritative — never trust the client.

Build order:
  1. Remotes folder + RemoteEvents. 2. TycoonConfig with prices/rates.
  3. leaderstats on PlayerAdded. 4. Plot claiming (assign a free plot, store owner).
  5. Purchase buttons (Touched -> check cash -> deduct -> unlock item).
  6. Dropper loop (spawn cash parts on a timer). 7. Collector (Touched -> add cash).
  8. DataStore save/load. 9. HUD.

USER MUST PROVIDE: the plot model(s) (baseplate, walls, button parts, collector,
dropper anchors), any decorative models, and icons for the HUD (use the icon library).]],

			simulator = [[SIMULATOR blueprint (advisory)

Core loop: player performs an action (click/swing) -> earns a primary currency ->
spends it on upgrades (tools/pets/zones) that multiply earnings -> rebirth resets
progress for a permanent multiplier.

Structure to build:
  ServerScriptService/
    SimCore (Script)         -- handles earn requests, validates, updates leaderstats
    ShopServer (Script)      -- upgrade/tool/pet purchases
    DataManager (Script)     -- save currencies, owned items, rebirths
  ReplicatedStorage/
    Remotes/ (Folder)
      DoAction (RemoteEvent)     -- client did the click/swing
      BuyUpgrade (RemoteEvent)
      Rebirth (RemoteEvent)
    SimConfig (ModuleScript)     -- currencies, upgrade costs/multipliers, rebirth curve
  Workspace/
    Zones/ (Folder)              -- areas the player unlocks (USER PROVIDES zone models)
  StarterGui/
    MainHUD (ScreenGui)          -- currency counters, shop button, rebirth button
    ShopMenu (ScreenGui)

leaderstats: "leaderstats" folder per player with IntValues for each currency
(e.g. Coins, Gems) + a "Rebirths" value. Server-authoritative.

Anti-exploit note: the client sends "I did the action", the SERVER decides the
reward (rate-limit it). Never let the client send the amount earned.

Build order:
  1. Remotes + SimConfig. 2. leaderstats. 3. Earn action (DoAction -> server adds currency).
  4. Shop/upgrades (BuyUpgrade -> deduct -> apply multiplier). 5. Rebirth.
  6. DataStore. 7. HUD + shop UI (see BUILDING UI; pull icons from the library).

USER MUST PROVIDE: the tool/pet models, zone models, and any map. Currency/shop
icons can come from the icon library.]],

			obby = [[OBBY blueprint (advisory)

Core loop: player spawns -> traverses stages -> touching a stage checkpoint saves
progress -> respawn returns to the last checkpoint -> reach the end.

Structure to build:
  ServerScriptService/
    CheckpointServer (Script)    -- on checkpoint Touched, set the player's stage attribute + respawn point
    DataManager (Script)         -- save highest stage reached (optional)
  ReplicatedStorage/
    Remotes/ (Folder)
      StageReached (RemoteEvent)  -- optional, to update UI
  Workspace/
    Stages/ (Folder)             -- Checkpoint parts numbered 1..N (USER PROVIDES the obby course/parts)
      Stage1 (Part, a SpawnLocation or checkpoint pad), Stage2, ...
  StarterGui/
    ObbyHUD (ScreenGui)          -- current stage / skip-stage button (gamepass?)

Progress: store the player's current stage in a leaderstats IntValue "Stage" (or
an attribute). On CharacterAdded, teleport them to their saved stage's checkpoint.
Use SpawnLocations with Neutral/AllowTeamChangeOnTouch, or teleport manually.

Build order:
  1. Stages folder with numbered checkpoint parts. 2. leaderstats "Stage".
  3. Checkpoint Touched handler (only advance forward). 4. Respawn-to-checkpoint
  on CharacterAdded. 5. Optional DataStore for highest stage. 6. HUD.

USER MUST PROVIDE: the actual obby course — all the jump parts, kill bricks,
moving platforms, and checkpoint pads. The scaffold is the progress/respawn logic.]],
		}

		if genre == "" then
			return "ERROR: 'genre' is required. Available blueprints: tycoon, simulator, obby. " ..
				"Pass e.g. {\"genre\": \"tycoon\"}."
		end
		local bp = BLUEPRINTS[genre]
		if not bp then
			return "No built-in blueprint for '" .. genre .. "'. Available: tycoon, simulator, obby. " ..
				"For other genres, adapt the closest one (most games reuse the same pieces: a Remotes " ..
				"folder, leaderstats, server-authoritative currency, a Config ModuleScript, and DataStore " ..
				"persistence) — or ask the user to describe the core loop and build from that."
		end
		return bp .. "\n\nThis is ADVISORY — nothing was created. Build it with your normal tools " ..
			"(create the folders/scripts, set up remotes, generate the save system with the DataStore " ..
			"pattern), import the models the user provides where the blueprint says USER MUST PROVIDE, " ..
			"and run lint_project + check_ui_layout when you're done."

	elseif name == "save_system" then
		-- ADVISORY: returns a battle-tested DataStore save-system ModuleScript
		-- (pcall retry w/ backoff, session locking, autosave, BindToClose) tuned
		-- to the requested currency keys. Creates NOTHING — you write this into a
		-- ModuleScript (e.g. ServerScriptService) and wire it to PlayerAdded/
		-- PlayerRemoving + leaderstats. This is the #1 thing beginners get wrong;
		-- use the generated code rather than hand-rolling DataStore calls.
		local storeName = (type(args.store_name) == "string" and args.store_name ~= "") and args.store_name or "PlayerData"

		-- Currency/value keys -> their default starting values. Accepts either a
		-- list of names (["Coins","Gems"]) or a map ({Coins=0, Gems=0}).
		local defaults = {}
		local order = {}
		if type(args.keys) == "table" then
			if #args.keys > 0 then
				for _, k in ipairs(args.keys) do
					if type(k) == "string" and k ~= "" then defaults[k] = 0; table.insert(order, k) end
				end
			else
				for k, v in pairs(args.keys) do
					if type(k) == "string" then defaults[k] = (type(v) == "number") and v or 0; table.insert(order, k) end
				end
				table.sort(order)
			end
		end
		if #order == 0 then defaults = { Coins = 0 }; order = { "Coins" } end

		local autosave = tonumber(args.autosave_interval) or 60

		-- Build the default-template + leaderstats lines from the keys.
		local templateLines, statLines, copyLines = {}, {}, {}
		for _, k in ipairs(order) do
			table.insert(templateLines, string.format("\t\t%s = %s,", k, tostring(defaults[k])))
			table.insert(statLines, string.format(
				"\tlocal %s = Instance.new(\"IntValue\")\n\t%s.Name = \"%s\"\n\t%s.Value = data.%s\n\t%s.Parent = stats",
				k:lower(), k:lower(), k, k:lower(), k, k:lower()))
			table.insert(copyLines, string.format("\t\tdata.%s = stats.%s.Value", k, k))
		end

		local module = table.concat({
			"--[[ SaveSystem (ModuleScript, place in ServerScriptService)",
			"     Generated save system: pcall retries, session locking, autosave,",
			"     and a BindToClose flush so no data is lost on shutdown.",
			"     Wire it from a server Script (see USAGE at the bottom). ]]",
			"",
			"local DataStoreService = game:GetService(\"DataStoreService\")",
			"local Players = game:GetService(\"Players\")",
			"local RunService = game:GetService(\"RunService\")",
			"",
			"local store = DataStoreService:GetDataStore(\"" .. storeName .. "\")",
			"local AUTOSAVE_INTERVAL = " .. autosave .. "  -- seconds",
			"local MAX_RETRIES = 5",
			"",
			"local DEFAULT_DATA = {",
			table.concat(templateLines, "\n"),
			"}",
			"",
			"local SaveSystem = {}",
			"local sessions = {}            -- [userId] = data table currently loaded",
			"",
			"-- Retry any DataStore call with exponential backoff. DataStore calls",
			"-- throttle/fail under load; never call them once and hope.",
			"local function retry(fn)",
			"\tlocal attempt, delay = 0, 1",
			"\twhile attempt < MAX_RETRIES do",
			"\t\tattempt += 1",
			"\t\tlocal ok, result = pcall(fn)",
			"\t\tif ok then return true, result end",
			"\t\twarn(\"[SaveSystem] DataStore attempt \" .. attempt .. \" failed: \" .. tostring(result))",
			"\t\ttask.wait(delay)",
			"\t\tdelay = math.min(delay * 2, 8)",
			"\tend",
			"\treturn false",
			"end",
			"",
			"local function copyDefaults()",
			"\tlocal t = {}",
			"\tfor k, v in pairs(DEFAULT_DATA) do t[k] = v end",
			"\treturn t",
			"end",
			"",
			"-- Session locking: stamp the saved data with a lock so a second server",
			"-- (e.g. a fast rejoin / teleport) can't load stale data and overwrite",
			"-- newer progress. We wait briefly for an old lock to clear, then take it.",
			"local SESSION_ID = game.JobId ~= \"\" and game.JobId or tostring(os.time())",
			"",
			"function SaveSystem.load(player)",
			"\tlocal key = \"player_\" .. player.UserId",
			"\tlocal data",
			"\tlocal ok = retry(function()",
			"\t\tdata = store:UpdateAsync(key, function(old)",
			"\t\t\told = old or { data = copyDefaults(), lock = nil, lockTime = 0 }",
			"\t\t\t-- If another session holds a fresh lock, don't steal it yet.",
			"\t\t\tif old.lock and old.lock ~= SESSION_ID and (os.time() - (old.lockTime or 0)) < 30 then",
			"\t\t\t\treturn nil  -- abort; caller will retry",
			"\t\t\tend",
			"\t\t\told.lock = SESSION_ID",
			"\t\t\told.lockTime = os.time()",
			"\t\t\told.data = old.data or copyDefaults()",
			"\t\t\t-- Backfill any newly-added default keys onto old saves.",
			"\t\t\tfor k, v in pairs(DEFAULT_DATA) do",
			"\t\t\t\tif old.data[k] == nil then old.data[k] = v end",
			"\t\t\tend",
			"\t\t\treturn old",
			"\t\tend)",
			"\tend)",
			"\tif not ok or not data then",
			"\t\twarn(\"[SaveSystem] load failed for \" .. player.Name .. \"; using defaults (NOT saving to avoid wipe).\")",
			"\t\tsessions[player.UserId] = false  -- false = do-not-save sentinel",
			"\t\treturn copyDefaults(), false",
			"\tend",
			"\tsessions[player.UserId] = data.data",
			"\treturn data.data, true",
			"end",
			"",
			"function SaveSystem.save(player)",
			"\tlocal current = sessions[player.UserId]",
			"\tif not current then return end  -- failed load or nothing to save",
			"\tlocal key = \"player_\" .. player.UserId",
			"\tretry(function()",
			"\t\tstore:UpdateAsync(key, function(old)",
			"\t\t\told = old or {}",
			"\t\t\t-- Only write if we still own the lock (or it's free).",
			"\t\t\tif old.lock and old.lock ~= SESSION_ID then return nil end",
			"\t\t\told.data = current",
			"\t\t\told.lock = SESSION_ID",
			"\t\t\told.lockTime = os.time()",
			"\t\t\treturn old",
			"\t\tend)",
			"\tend)",
			"end",
			"",
			"function SaveSystem.release(player)",
			"\tlocal current = sessions[player.UserId]",
			"\tlocal key = \"player_\" .. player.UserId",
			"\tif current then",
			"\t\tretry(function()",
			"\t\t\tstore:UpdateAsync(key, function(old)",
			"\t\t\t\told = old or {}",
			"\t\t\t\tif old.lock and old.lock ~= SESSION_ID then return nil end",
			"\t\t\t\told.data = current",
			"\t\t\t\told.lock = nil       -- release the session lock",
			"\t\t\t\treturn old",
			"\t\t\tend)",
			"\t\tend)",
			"\tend",
			"\tsessions[player.UserId] = nil",
			"end",
			"",
			"function SaveSystem.get(player) return sessions[player.UserId] end",
			"",
			"-- Autosave loop.",
			"task.spawn(function()",
			"\twhile true do",
			"\t\ttask.wait(AUTOSAVE_INTERVAL)",
			"\t\tfor _, player in ipairs(Players:GetPlayers()) do",
			"\t\t\ttask.spawn(SaveSystem.save, player)",
			"\t\tend",
			"\tend",
			"end)",
			"",
			"-- Flush everyone on server shutdown (Studio respects this too).",
			"game:BindToClose(function()",
			"\tif RunService:IsStudio() then task.wait(1) end",
			"\tfor _, player in ipairs(Players:GetPlayers()) do",
			"\t\ttask.spawn(SaveSystem.release, player)",
			"\tend",
			"\ttask.wait(3)  -- give the async saves a moment",
			"end)",
			"",
			"return SaveSystem",
		}, "\n")

		local usage = table.concat({
			"--[[ USAGE — a server Script that wires SaveSystem to leaderstats:",
			"",
			"local Players = game:GetService(\"Players\")",
			"local SaveSystem = require(game.ServerScriptService.SaveSystem)",
			"",
			"local function onJoin(player)",
			"\tlocal data = SaveSystem.load(player)",
			"\tlocal stats = Instance.new(\"Folder\")",
			"\tstats.Name = \"leaderstats\"",
			table.concat(statLines, "\n"),
			"\tstats.Parent = player",
			"\t-- Keep the saved table in sync when a value changes:",
			"\tfor _, v in ipairs(stats:GetChildren()) do",
			"\t\tv.Changed:Connect(function()",
			"\t\t\tlocal d = SaveSystem.get(player)",
			"\t\t\tif d then d[v.Name] = v.Value end",
			"\t\tend)",
			"\tend",
			"end",
			"",
			"Players.PlayerAdded:Connect(onJoin)",
			"for _, p in ipairs(Players:GetPlayers()) do onJoin(p) end  -- handle the Studio-start race",
			"Players.PlayerRemoving:Connect(function(p) SaveSystem.release(p) end)",
			"]]",
		}, "\n")

		return "ADVISORY save system for store \"" .. storeName .. "\" with keys: " ..
			table.concat(order, ", ") .. " (autosave every " .. autosave .. "s).\n" ..
			"Write the MODULE below into a ModuleScript named 'SaveSystem' in " ..
			"ServerScriptService, then add a server Script using the USAGE block.\n" ..
			"IMPORTANT: DataStores need 'Enable Studio Access to API Services' ON " ..
			"(Game Settings > Security) and the place must be published, or every " ..
			"save will error — tell the user to enable it.\n\n" ..
			"===== MODULE (ServerScriptService.SaveSystem) =====\n" .. module ..
			"\n\n" .. usage ..
			"\n\nNothing was created — build these with your normal tools and run " ..
			"lint_project afterward."

	elseif name == "ui_animation" then
		-- ADVISORY: returns ready-to-use TweenService recipe code for common UI
		-- motions. These are RUNTIME behaviors (they fire on events at play time),
		-- so they go in a LocalScript — nothing animates at edit time. Creates
		-- nothing; you adapt the snippet and wire it to the right event.
		local kind = type(args.kind) == "string" and args.kind:lower():gsub("%s+", "") or "all"

		local RECIPES = {
			slide = [[SLIDE-IN / SLIDE-OUT MENU (LocalScript)
Tween a panel's Position on/off screen. Park the closed position off the edge
and the open position where it belongs; toggle between them.

local TweenService = game:GetService("TweenService")
local panel = script.Parent            -- the Frame to slide (AnchorPoint 0.5,0.5)
local OPEN  = UDim2.fromScale(0.5, 0.5)
local SHUT  = UDim2.fromScale(0.5, 1.5) -- below the screen
local info = TweenInfo.new(0.35, Enum.EasingStyle.Quint, Enum.EasingDirection.Out)

local open = false
local function setOpen(state)
	open = state
	if open then panel.Visible = true end
	local tw = TweenService:Create(panel, info, { Position = open and OPEN or SHUT })
	tw:Play()
	if not open then tw.Completed:Once(function() panel.Visible = false end) end
end

-- Bind to your open/close buttons:
-- openButton.MouseButton1Click:Connect(function() setOpen(true) end)
-- closeButton.MouseButton1Click:Connect(function() setOpen(false) end)]],

			button = [[BUTTON HOVER + PRESS BOUNCE (LocalScript)
Scale the button up slightly on hover and "press" it on click, with a small
sound. Give the button a UIScale child (or tween Size) — UIScale is cleaner.

local TweenService = game:GetService("TweenService")
local button = script.Parent             -- a TextButton/ImageButton
local scale = button:FindFirstChildOfClass("UIScale") or Instance.new("UIScale", button)
local quick = TweenInfo.new(0.12, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)

local function to(s) TweenService:Create(scale, quick, { Scale = s }):Play() end

button.MouseEnter:Connect(function() to(1.08) end)
button.MouseLeave:Connect(function() to(1.0) end)
button.MouseButton1Down:Connect(function() to(0.94) end)
button.MouseButton1Up:Connect(function() to(1.08) end)
button.MouseButton1Click:Connect(function()
	-- optional click sound: local s = Instance.new("Sound"); s.SoundId = "rbxassetid://..."; s.Parent = button; s:Play()
end)]],

			toast = [[NOTIFICATION TOAST (LocalScript / ModuleScript)
Spawn a small label, slide it in from the top, hold, then fade+slide out and
clean up. Call notify("Saved!") from anywhere.

local TweenService = game:GetService("TweenService")
local gui = script.Parent              -- a ScreenGui to hold toasts
local inInfo  = TweenInfo.new(0.3, Enum.EasingStyle.Back, Enum.EasingDirection.Out)
local outInfo = TweenInfo.new(0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.In)

local function notify(text, holdSeconds)
	holdSeconds = holdSeconds or 2
	local toast = Instance.new("TextLabel")
	toast.Size = UDim2.fromScale(0.3, 0.07)
	toast.AnchorPoint = Vector2.new(0.5, 0)
	toast.Position = UDim2.fromScale(0.5, -0.1)   -- start off the top edge
	toast.BackgroundColor3 = Color3.fromRGB(30, 32, 42)
	toast.TextColor3 = Color3.fromRGB(235, 238, 245)
	toast.Font = Enum.Font.GothamMedium
	toast.TextScaled = true
	toast.Text = text
	Instance.new("UICorner", toast).CornerRadius = UDim.new(0, 8)
	toast.Parent = gui

	TweenService:Create(toast, inInfo, { Position = UDim2.fromScale(0.5, 0.04) }):Play()
	task.delay(holdSeconds, function()
		local out = TweenService:Create(toast, outInfo, {
			Position = UDim2.fromScale(0.5, -0.1), TextTransparency = 1, BackgroundTransparency = 1,
		})
		out:Play()
		out.Completed:Once(function() toast:Destroy() end)
	end)
end

-- notify("Welcome back!")  /  notify("Not enough coins", 1.5)
return notify]],
		}

		if kind == "all" then
			return "ADVISORY UI animation recipes (TweenService, runtime/LocalScript — nothing created):\n\n" ..
				RECIPES.slide .. "\n\n" .. RECIPES.button .. "\n\n" .. RECIPES.toast ..
				"\n\nPick the one(s) you need, write them into a LocalScript, and wire to the right " ..
				"event. All use TweenService with sensible easing — adjust durations/colors to taste."
		end
		local r = RECIPES[kind]
		if not r then
			return "Unknown kind '" .. kind .. "'. Available: slide, button, toast (or 'all' for every one)."
		end
		return "ADVISORY (TweenService, runtime/LocalScript — nothing created):\n\n" .. r ..
			"\n\nWrite this into a LocalScript and wire it to the right event. Adjust timing/colors to taste."

		-- ── Asset info (validate an asset_id before insert_model) ──
	elseif name == "get_asset_info" then
		local id = tonumber(args.asset_id)
		if not id then return "ERROR: numeric 'asset_id' is required" end
		local ok, info = pcall(function()
			return game:GetService("MarketplaceService"):GetProductInfo(id)
		end)
		if not ok then return "ERROR fetching asset " .. id .. ": " .. tostring(info) end
		if type(info) ~= "table" then return "No info returned for asset " .. id .. "." end
		local creator = (type(info.Creator) == "table" and info.Creator.Name) or "?"
		local out = {
			"Asset " .. id .. ":",
			"  Name:        " .. tostring(info.Name),
			"  AssetTypeId: " .. tostring(info.AssetTypeId),
			"  Creator:     " .. tostring(creator),
			"  ForSale:     " .. tostring(info.IsForSale),
			"  Price:       " .. tostring(info.PriceInRobux or 0),
		}
		if info.Description and info.Description ~= "" then
			local d = tostring(info.Description)
			if #d > 200 then d = d:sub(1, 200) .. "..." end
			out[#out + 1] = "  Description: " .. d
		end
		return table.concat(out, "\n")

		-- ── Project-wide find & replace ─────────────────────────────
	elseif name == "replace_in_scripts" then
		local find = args.find
		if type(find) ~= "string" or find == "" then return "ERROR: 'find' (search string) is required" end
		local replace = args.replace
		if type(replace) ~= "string" then return "ERROR: 'replace' (string) is required" end
		local useRegex = args.regex == true
		local root = game
		if args.path and args.path ~= "" then
			local r, perr = resolvePath(args.path)
			if not r then return "ERROR: " .. perr end
			root = r
		end
		local targets = {}
		local function scan(inst)
			if inst:IsA("LuaSourceContainer") then targets[#targets + 1] = inst end
			local ok, ch = pcall(function() return inst:GetChildren() end)
			if ok then for _, c in ipairs(ch) do scan(c) end end
		end
		scan(root)
		local plan, totalHits, broken = {}, 0, {}
		local toWrite = {}
		for _, inst in ipairs(targets) do
			local ok, srcText = pcall(function() return inst.Source end)
			if ok and type(srcText) == "string" then
				local newSrc, n
				if useRegex then
					ok, newSrc, n = pcall(function() return srcText:gsub(find, replace) end)
					if not ok then return "ERROR: bad Lua pattern in 'find': " .. tostring(newSrc) end
				else
					newSrc, n = srcText:gsub(find:gsub("(%W)", "%%%1"), (replace:gsub("%%", "%%%%")))
				end
				if n > 0 then
					totalHits = totalHits + n
					local okSyn = validateLuauSyntax(newSrc)
					if okSyn then
						toWrite[#toWrite + 1] = { inst = inst, src = newSrc }
						plan[#plan + 1] = string.format("  %s — %d replacement(s)", inst:GetFullName(), n)
					else
						broken[#broken + 1] = inst:GetFullName()
					end
				end
			end
		end
		if totalHits == 0 then return "No matches for '" .. find .. "' in " .. #targets .. " script(s)." end
		local head = string.format("%d replacement(s) across %d script(s):", totalHits, #toWrite)
		local body = head .. "\n" .. table.concat(plan, "\n")
		if #broken > 0 then
			body = body .. "\n⚠️ SKIPPED (would break syntax): " .. table.concat(broken, ", ")
		end
		if args.dry_run then return "DRY RUN — " .. body .. "\nNo changes made." end
		pcall(function() ChangeHistoryService:SetWaypoint("Before AI replace_in_scripts") end)
		for _, w in ipairs(toWrite) do
			pcall(function() w.inst.Source = w.src end)
		end
		pcall(function() ChangeHistoryService:SetWaypoint("After AI replace_in_scripts") end)
		aiWaypoints = aiWaypoints + 1
		changeLog[#changeLog + 1] = { time = os.date("!%H:%M:%S"), label = "replace_in_scripts '" .. find .. "'" }
		return "✅ Applied — " .. body .. "\n(Single Ctrl+Z reverts all edits.)"

		-- ── Find instances by property value ────────────────────────
	elseif name == "find_by_property" then
		if type(args.property) ~= "string" or args.property == "" then return "ERROR: 'property' is required" end
		local want = parseValueString(args.value)
		local wantStr = tostring(args.value)
		local partial = args.partial == true
		local root = game
		if args.path and args.path ~= "" then
			local r, perr = resolvePath(args.path)
			if not r then return "ERROR: " .. perr end
			root = r
		end
		local results = {}
		local function scan(inst)
			if (not args.class) or inst.ClassName == args.class then
				local ok, got = pcall(function() return inst[args.property] end)
				if ok and got ~= nil then
					local gotStr = valueToString(got)
					local hit
					if partial then
						hit = gotStr:lower():find(wantStr:lower(), 1, true) ~= nil
					else
						hit = (got == want) or (gotStr == wantStr) or (gotStr == valueToString(want))
					end
					if hit then
						results[#results + 1] = "[" .. inst.ClassName .. "] " .. inst:GetFullName() .. "  (" .. args.property .. " = " .. gotStr .. ")"
						if #results >= 100 then return true end
					end
				end
			end
			local ok, ch = pcall(function() return inst:GetChildren() end)
			if ok then for _, c in ipairs(ch) do if scan(c) then return true end end end
		end
		scan(root)
		if #results == 0 then return "No instances where " .. args.property .. " matches '" .. wantStr .. "'." end
		local out = { "Found " .. #results .. " instance(s):" }
		for _, r in ipairs(results) do out[#out + 1] = "  " .. r end
		if #results == 100 then out[#out + 1] = "  (limit 100 — refine)" end
		return table.concat(out, "\n")

		-- ── Builder: duplicate along an offset (arrays) ─────────────
	elseif name == "duplicate_array" then
		local inst, err = resolvePath(args.path)
		if not inst then return "ERROR: " .. err end
		local count = math.floor(tonumber(args.count) or 0)
		if count < 1 then return "ERROR: 'count' must be >= 1" end
		if count > 200 then return "ERROR: count capped at 200 per call" end
		local off = args.offset or {0, 0, 0}
		local rot = args.rotation or {0, 0, 0}
		local parent = inst.Parent
		if args.parent then
			local p, perr = resolvePath(args.parent)
			if not p then return "ERROR: " .. perr end
			parent = p
		end
		local made = 0
		local _, dErr = withWaypoint("AI duplicate_array x" .. count, function()
			local okPivot, base = pcall(function() return inst:GetPivot() end)
			for i = 1, count do
				local clone = inst:Clone()
				clone.Parent = parent
				if okPivot then
					local cf = base + Vector3.new((off[1] or 0) * i, (off[2] or 0) * i, (off[3] or 0) * i)
					if (rot[1] or 0) ~= 0 or (rot[2] or 0) ~= 0 or (rot[3] or 0) ~= 0 then
						cf = cf * CFrame.Angles(math.rad((rot[1] or 0) * i), math.rad((rot[2] or 0) * i), math.rad((rot[3] or 0) * i))
					end
					pcall(function() clone:PivotTo(cf) end)
				end
				made = made + 1
			end
		end)
		if dErr then return "ERROR duplicating: " .. dErr end
		return "✅ Created " .. made .. " copies of " .. inst.Name .. " under " .. parent:GetFullName()

		-- ── Builder: align on an axis ───────────────────────────────
	elseif name == "align_instances" then
		local paths = args.paths or {}
		if #paths < 2 then return "ERROR: 'paths' needs at least 2 instances" end
		local axis = (args.axis or "x"):lower()
		local idx = (axis == "x" and 1) or (axis == "y" and 2) or (axis == "z" and 3)
		if not idx then return "ERROR: 'axis' must be x, y or z" end
		local mode = (args.mode or "center"):lower()
		local insts, positions = {}, {}
		for _, p in ipairs(paths) do
			local it, e = resolvePath(p)
			if not it then return "ERROR: " .. e end
			local ok, piv = pcall(function() return it:GetPivot() end)
			if ok then insts[#insts + 1] = { it = it, piv = piv }; positions[#positions + 1] = piv.Position[({"X","Y","Z"})[idx]] end
		end
		if #insts == 0 then return "ERROR: none of the paths have a pivot/position" end
		local target
		if mode == "min" then target = math.min(table.unpack(positions))
		elseif mode == "max" then target = math.max(table.unpack(positions))
		else local s = 0; for _, v in ipairs(positions) do s = s + v end; target = s / #positions end
		local _, aErr = withWaypoint("AI align " .. axis, function()
			for _, rec in ipairs(insts) do
				local pos = rec.piv.Position
				local newPos = Vector3.new(idx == 1 and target or pos.X, idx == 2 and target or pos.Y, idx == 3 and target or pos.Z)
				pcall(function() rec.it:PivotTo(rec.piv - pos + newPos) end)
			end
		end)
		if aErr then return "ERROR aligning: " .. aErr end
		return "✅ Aligned " .. #insts .. " instance(s) on " .. axis:upper() .. " (" .. mode .. " = " .. string.format("%.2f", target) .. ")"

		-- ── Builder: distribute evenly on an axis ───────────────────
	elseif name == "distribute_instances" then
		local paths = args.paths or {}
		if #paths < 3 then return "ERROR: 'paths' needs at least 3 instances to distribute" end
		local axis = (args.axis or "x"):lower()
		local comp = (axis == "x" and "X") or (axis == "y" and "Y") or (axis == "z" and "Z")
		if not comp then return "ERROR: 'axis' must be x, y or z" end
		local insts = {}
		for _, p in ipairs(paths) do
			local it, e = resolvePath(p)
			if not it then return "ERROR: " .. e end
			local ok, piv = pcall(function() return it:GetPivot() end)
			if ok then insts[#insts + 1] = { it = it, piv = piv, v = piv.Position[comp] } end
		end
		table.sort(insts, function(a, b) return a.v < b.v end)
		local lo, hi = insts[1].v, insts[#insts].v
		local step = (hi - lo) / (#insts - 1)
		local _, dErr = withWaypoint("AI distribute " .. axis, function()
			for i, rec in ipairs(insts) do
				local targetV = lo + step * (i - 1)
				local pos = rec.piv.Position
				local newPos = Vector3.new(comp == "X" and targetV or pos.X, comp == "Y" and targetV or pos.Y, comp == "Z" and targetV or pos.Z)
				pcall(function() rec.it:PivotTo(rec.piv - pos + newPos) end)
			end
		end)
		if dErr then return "ERROR distributing: " .. dErr end
		return "✅ Distributed " .. #insts .. " instance(s) evenly on " .. axis:upper()

		-- ── Builder: weld parts together ────────────────────────────
	elseif name == "weld_parts" then
		local paths = args.paths or {}
		if #paths < 2 then return "ERROR: 'paths' needs at least 2 parts (first is the anchor)" end
		local parts = {}
		for _, p in ipairs(paths) do
			local it, e = resolvePath(p)
			if not it then return "ERROR: " .. e end
			if not it:IsA("BasePart") then return "ERROR: " .. p .. " is not a BasePart" end
			parts[#parts + 1] = it
		end
		local base = parts[1]
		local welds = 0
		local _, wErr = withWaypoint("AI weld_parts", function()
			for i = 2, #parts do
				local wc = Instance.new("WeldConstraint")
				wc.Part0 = base
				wc.Part1 = parts[i]
				wc.Parent = base
				welds = welds + 1
			end
			if args.anchor_base ~= false then
				base.Anchored = true
				for i = 2, #parts do parts[i].Anchored = false end
			end
		end)
		if wErr then return "ERROR welding: " .. wErr end
		return "✅ Welded " .. welds .. " part(s) to " .. base.Name .. (args.anchor_base ~= false and " (base anchored)" or "")

		-- ── Set multiple properties / attributes at once ───────────
	elseif name == "set_properties" then
		local props = args.properties
		if type(props) ~= "table" then return "ERROR: 'properties' must be an object of name=value pairs" end
		local types = args.types or {}
		inBatch = true
		local results, applied = {}, 0
		for prop, value in pairs(props) do
			local r = executeTool("set_property", { path = args.path, property = prop, value = value, type = types[prop], dry_run = args.dry_run })
			results[#results + 1] = "  " .. prop .. " → " .. tostring(r):gsub("\n", " "):sub(1, 80)
			if tostring(r):sub(1, 5) ~= "ERROR" then applied = applied + 1 end
		end
		inBatch = false
		if not args.dry_run and applied > 0 then aiWaypoints = aiWaypoints + 1 end
		return (args.dry_run and "DRY RUN — " or "✅ ") .. applied .. " property write(s) on " .. tostring(args.path) .. ":\n" .. table.concat(results, "\n")

	elseif name == "set_attributes" then
		local attrs = args.attributes
		if type(attrs) ~= "table" then return "ERROR: 'attributes' must be an object of name=value pairs" end
		inBatch = true
		local results, applied = {}, 0
		for nm, value in pairs(attrs) do
			local r = executeTool("set_attribute", { path = args.path, name = nm, value = value, dry_run = args.dry_run })
			results[#results + 1] = "  " .. nm .. " → " .. tostring(r):gsub("\n", " "):sub(1, 80)
			if tostring(r):sub(1, 5) ~= "ERROR" then applied = applied + 1 end
		end
		inBatch = false
		if not args.dry_run and applied > 0 then aiWaypoints = aiWaypoints + 1 end
		return (args.dry_run and "DRY RUN — " or "✅ ") .. applied .. " attribute write(s) on " .. tostring(args.path) .. ":\n" .. table.concat(results, "\n")

		-- ── Remote scaffolding ──────────────────────────────────────
	elseif name == "create_remote" then
		local nm = args.name
		if type(nm) ~= "string" or nm == "" then return "ERROR: 'name' is required" end
		local rtype = args.type or "RemoteEvent"
		local valid = { RemoteEvent = true, RemoteFunction = true, BindableEvent = true, BindableFunction = true }
		if not valid[rtype] then return "ERROR: 'type' must be RemoteEvent|RemoteFunction|BindableEvent|BindableFunction" end
		local parentPath = args.parent or "ReplicatedStorage"
		local parent, perr = resolvePath(parentPath)
		if not parent then return "ERROR: " .. perr end
		local created = {}
		local _, cErr = withWaypoint("AI create_remote " .. nm, function()
			local remote = Instance.new(rtype)
			remote.Name = nm
			remote.Parent = parent
			created[#created + 1] = remote:GetFullName()
			if args.with_handlers and (rtype == "RemoteEvent" or rtype == "RemoteFunction") then
				local sss = game:GetService("ServerScriptService")
				local genOpts = { params = args.params, rate_limit = args.rate_limit }
				local serverSrc = buildRemoteHandlerSource("server", nm, rtype, genOpts)
				local sScript = Instance.new("Script")
				sScript.Name = nm .. "_Server"
				sScript.Source = serverSrc
				sScript.Parent = sss
				created[#created + 1] = sScript:GetFullName()
				local sps = game:GetService("StarterPlayer"):FindFirstChild("StarterPlayerScripts")
				if sps then
					local clientSrc = buildRemoteHandlerSource("client", nm, rtype, genOpts)
					local cScript = Instance.new("LocalScript")
					cScript.Name = nm .. "_Client"
					cScript.Source = clientSrc
					cScript.Parent = sps
					created[#created + 1] = cScript:GetFullName()
				end
			end
		end)
		if cErr then return "ERROR creating remote: " .. cErr end
		return "✅ Created:\n  " .. table.concat(created, "\n  ")

		-- ── Spatial queries ──────────────────────────────────────
	elseif name == "raycast" then
		local Workspace = game:GetService("Workspace")
		local origin = args.origin or args.from
		if type(origin) ~= "table" or #origin < 3 then
			return "ERROR: 'origin' (or 'from') must be a [x,y,z] array"
		end
		local originVec = Vector3.new(origin[1], origin[2], origin[3])

		local dirVec
		if type(args.direction) == "table" and #args.direction >= 3 then
			dirVec = Vector3.new(args.direction[1], args.direction[2], args.direction[3])
		elseif type(args.to) == "table" and #args.to >= 3 then
			dirVec = Vector3.new(args.to[1], args.to[2], args.to[3]) - originVec
		else
			return "ERROR: provide either 'direction' [x,y,z] or 'to' [x,y,z]"
		end
		local maxDist = tonumber(args.max_distance)
		if maxDist and dirVec.Magnitude > 0 then
			dirVec = dirVec.Unit * maxDist
		end

		local rp = RaycastParams.new()
		if type(args.ignore) == "table" and #args.ignore > 0 then
			local ignoreInsts = {}
			for _, p in ipairs(args.ignore) do
				local inst = resolvePath(p)
				if inst then table.insert(ignoreInsts, inst) end
			end
			rp.FilterDescendantsInstances = ignoreInsts
			rp.FilterType = Enum.RaycastFilterType.Exclude
		end
		rp.IgnoreWater = args.ignore_water == true

		local ok, result = pcall(function() return Workspace:Raycast(originVec, dirVec, rp) end)
		if not ok then return "ERROR raycasting: " .. tostring(result) end

		if not result then
			return string.format(
				"No hit. Ray from (%.2f, %.2f, %.2f) in direction (%.2f, %.2f, %.2f), length %.2f studs.",
				originVec.X, originVec.Y, originVec.Z, dirVec.Unit.X, dirVec.Unit.Y, dirVec.Unit.Z, dirVec.Magnitude
			)
		end
		local pos, normal = result.Position, result.Normal
		return string.format(
			"Hit %s at (%.2f, %.2f, %.2f), %.2f studs away, surface normal (%.2f, %.2f, %.2f), material %s",
			result.Instance:GetFullName(), pos.X, pos.Y, pos.Z, (pos - originVec).Magnitude,
			normal.X, normal.Y, normal.Z, tostring(result.Material)
		)

	elseif name == "find_parts_near" then
		local Workspace = game:GetService("Workspace")
		local center = args.center or args.position
		if type(center) ~= "table" or #center < 3 then
			return "ERROR: 'center' (or 'position') must be a [x,y,z] array"
		end
		local centerVec = Vector3.new(center[1], center[2], center[3])
		local radius = tonumber(args.radius) or 10
		local maxResults = math.min(tonumber(args.max_results) or 30, 100)

		local ok, parts = pcall(function() return Workspace:GetPartBoundsInRadius(centerVec, radius) end)
		if not ok then return "ERROR querying region: " .. tostring(parts) end
		if #parts == 0 then
			return string.format("No parts found within %.1f studs of (%.2f, %.2f, %.2f).",
				radius, centerVec.X, centerVec.Y, centerVec.Z)
		end

		table.sort(parts, function(a, b)
			return (a.Position - centerVec).Magnitude < (b.Position - centerVec).Magnitude
		end)

		local lines = {}
		for i, part in ipairs(parts) do
			if i > maxResults then break end
			table.insert(lines, string.format("%.2f studs  %s  (%s)",
				(part.Position - centerVec).Magnitude, part:GetFullName(), part.ClassName))
		end
		local out = table.concat(lines, "\n")
		if #parts > maxResults then
			out = out .. "\n... and " .. (#parts - maxResults) .. " more (raise max_results to see them)."
		end
		return out

		-- ── Terrain ──────────────────────────────────────────────
	elseif name == "edit_terrain" then
		local terrain = game:GetService("Workspace").Terrain
		local shape = args.shape

		if shape == "clear" then
			local _, cErr = withWaypoint("AI clear terrain", function()
				if type(args.region) == "table" and args.region.min and args.region.max then
					local mn, mx = args.region.min, args.region.max
					local region = Region3.new(
						Vector3.new(mn[1], mn[2], mn[3]), Vector3.new(mx[1], mx[2], mx[3])
					):ExpandToGrid(4)
					terrain:FillRegion(region, 4, Enum.Material.Air)
				else
					terrain:Clear()
				end
			end)
			if cErr then return "ERROR clearing terrain: " .. cErr end
			return "✅ Cleared terrain" .. (args.region and " in the given region." or " (entire map — undo with Ctrl+Z if that wasn't intended).")
		end

		local matOk, material = pcall(function() return Enum.Material[args.material or "Grass"] end)
		if not matOk or not material then
			return "ERROR: invalid 'material' — try Grass, Rock, Sand, Sandstone, Concrete, Ice, Snow, Mud, Water, Asphalt, Basalt, etc."
		end

		local function rotatedCFrame(pos, rot)
			local cf = CFrame.new(pos[1], pos[2], pos[3])
			if type(rot) == "table" then
				cf = cf * CFrame.Angles(math.rad(rot[1] or 0), math.rad(rot[2] or 0), math.rad(rot[3] or 0))
			end
			return cf
		end

		if shape == "block" then
			if type(args.position) ~= "table" or type(args.size) ~= "table" then
				return "ERROR: 'position' and 'size' ([x,y,z] arrays) are required for shape=block"
			end
			local cf = rotatedCFrame(args.position, args.rotation)
			local _, cErr = withWaypoint("AI fill terrain block", function()
				terrain:FillBlock(cf, Vector3.new(args.size[1], args.size[2], args.size[3]), material)
			end)
			if cErr then return "ERROR filling terrain: " .. cErr end
			return "✅ Filled a " .. material.Name .. " block."

		elseif shape == "ball" then
			local center = args.center or args.position
			local radius = tonumber(args.radius)
			if type(center) ~= "table" or not radius then
				return "ERROR: 'center' ([x,y,z]) and 'radius' are required for shape=ball"
			end
			local _, cErr = withWaypoint("AI fill terrain ball", function()
				terrain:FillBall(Vector3.new(center[1], center[2], center[3]), radius, material)
			end)
			if cErr then return "ERROR filling terrain: " .. cErr end
			return "✅ Filled a " .. material.Name .. " ball, radius " .. radius .. "."

		elseif shape == "cylinder" then
			local height, radius = tonumber(args.height), tonumber(args.radius)
			if type(args.position) ~= "table" or not height or not radius then
				return "ERROR: 'position', 'height', and 'radius' are required for shape=cylinder"
			end
			local cf = rotatedCFrame(args.position, args.rotation)
			local _, cErr = withWaypoint("AI fill terrain cylinder", function()
				terrain:FillCylinder(cf, height, radius, material)
			end)
			if cErr then return "ERROR filling terrain: " .. cErr end
			return "✅ Filled a " .. material.Name .. " cylinder."

		elseif shape == "wedge" then
			if type(args.position) ~= "table" or type(args.size) ~= "table" then
				return "ERROR: 'position' and 'size' are required for shape=wedge"
			end
			local cf = rotatedCFrame(args.position, args.rotation)
			local _, cErr = withWaypoint("AI fill terrain wedge", function()
				terrain:FillWedge(cf, Vector3.new(args.size[1], args.size[2], args.size[3]), material)
			end)
			if cErr then return "ERROR filling terrain: " .. cErr end
			return "✅ Filled a " .. material.Name .. " wedge."
		end

		return "ERROR: 'shape' must be one of block|ball|cylinder|wedge|clear"

		-- ── Physics constraints ──────────────────────────────────
	elseif name == "create_constraint" then
		local part0, e0 = resolvePath(args.part0)
		if not part0 then return "ERROR resolving part0: " .. e0 end
		local part1, e1 = resolvePath(args.part1)
		if not part1 then return "ERROR resolving part1: " .. e1 end
		if not (part0:IsA("BasePart") or part0:IsA("Attachment")) then
			return "ERROR: part0 must be a BasePart (or an existing Attachment)"
		end
		if not (part1:IsA("BasePart") or part1:IsA("Attachment")) then
			return "ERROR: part1 must be a BasePart (or an existing Attachment)"
		end

		local validTypes = {
			Hinge = true, Spring = true, Rope = true, Rod = true, Weld = true,
			Ball = true, Cylindrical = true, Prismatic = true, Universal = true,
			Torsion = true, LineForce = true, AlignPosition = true, AlignOrientation = true,
		}
		local ctype = args.type
		if not validTypes[ctype] then
			local names = {}
			for k in pairs(validTypes) do table.insert(names, k) end
			table.sort(names)
			return "ERROR: 'type' must be one of " .. table.concat(names, "|")
		end
		local className = ctype .. "Constraint"

		local result, cErr = withWaypoint("AI create " .. className, function()
			local function getOrMakeAttachment(part, offset, attName)
				if part:IsA("Attachment") then return part end
				local att = Instance.new("Attachment")
				att.Name = attName
				if type(offset) == "table" and #offset >= 3 then
					att.Position = Vector3.new(offset[1], offset[2], offset[3])
				end
				att.Parent = part
				return att
			end
			local att0 = getOrMakeAttachment(part0, args.attachment0_offset, className .. "Attachment0")
			local att1 = getOrMakeAttachment(part1, args.attachment1_offset, className .. "Attachment1")

			local c = Instance.new(className)
			c.Name = className
			c.Attachment0 = att0
			c.Attachment1 = att1
			if type(args.properties) == "table" then
				for prop, val in pairs(args.properties) do
					coerceAndSet(c, prop, val)  -- best-effort; bad props are just skipped
				end
			end
			c.Parent = part0:IsA("BasePart") and part0 or part0.Parent
			return c
		end)
		if cErr then return "ERROR creating constraint: " .. cErr end
		local p0name = part0:IsA("Attachment") and part0.Parent:GetFullName() or part0:GetFullName()
		local p1name = part1:IsA("Attachment") and part1.Parent:GetFullName() or part1:GetFullName()
		return "✅ Created " .. result:GetFullName() .. " between " .. p0name .. " and " .. p1name ..
			". Both parts need CanCollide handled and at least one should be unanchored for it to move."

		-- ── Audio ──────────────────────────────────────────────
	elseif name == "create_sound" then
		local parent, perr = resolvePath(args.parent or "SoundService")
		if not parent then return "ERROR resolving parent: " .. perr end
		if args.sound_id == nil then
			return "ERROR: 'sound_id' is required (e.g. 'rbxassetid://1234567' or a numeric asset id)"
		end
		local soundId = tostring(args.sound_id)
		if soundId:match("^%d+$") then soundId = "rbxassetid://" .. soundId end

		local result, cErr = withWaypoint("AI create sound '" .. (args.name or "Sound") .. "'", function()
			local s = Instance.new("Sound")
			s.Name = (type(args.name) == "string" and args.name ~= "") and args.name or "Sound"
			s.SoundId = soundId
			s.Volume = tonumber(args.volume) or 0.5
			s.Looped = args.looped == true
			s.PlaybackSpeed = tonumber(args.playback_speed) or 1
			if args.roll_off_min_distance then s.RollOffMinDistance = tonumber(args.roll_off_min_distance) end
			if args.roll_off_max_distance then s.RollOffMaxDistance = tonumber(args.roll_off_max_distance) end
			s.Parent = parent
			return s
		end)
		if cErr then return "ERROR creating sound: " .. cErr end

		local preview = args.preview ~= false
		if preview then pcall(function() result:Play() end) end
		return "✅ Created " .. result:GetFullName() ..
			(preview and " and started playing it for preview." or ".") ..
			" Use play_sound/stop_sound to control it later."

	elseif name == "play_sound" then
		local s, serr = resolvePath(args.path)
		if not s then return "ERROR resolving path: " .. serr end
		if not s:IsA("Sound") then return "ERROR: '" .. tostring(args.path) .. "' is not a Sound" end
		local ok, err = pcall(function()
			s.TimePosition = 0
			s:Play()
		end)
		if not ok then return "ERROR playing sound: " .. tostring(err) end
		return "▶️ Playing " .. s:GetFullName()

	elseif name == "stop_sound" then
		local s, serr = resolvePath(args.path)
		if not s then return "ERROR resolving path: " .. serr end
		if not s:IsA("Sound") then return "ERROR: '" .. tostring(args.path) .. "' is not a Sound" end
		local ok, err = pcall(function() s:Stop() end)
		if not ok then return "ERROR stopping sound: " .. tostring(err) end
		return "⏹️ Stopped " .. s:GetFullName()

		-- ── Lighting & atmosphere ────────────────────────────────
	elseif name == "set_scene_mood" then
		local Lighting = game:GetService("Lighting")
		local applied = {}

		local _, cErr = withWaypoint("AI set scene mood", function()
			if type(args.lighting) == "table" then
				for prop, val in pairs(args.lighting) do
					local ok = coerceAndSet(Lighting, prop, val)
					if ok then table.insert(applied, "Lighting." .. prop) end
				end
			end
			if type(args.atmosphere) == "table" then
				local atmo = Lighting:FindFirstChildOfClass("Atmosphere")
				if not atmo then atmo = Instance.new("Atmosphere"); atmo.Parent = Lighting end
				for prop, val in pairs(args.atmosphere) do
					local ok = coerceAndSet(atmo, prop, val)
					if ok then table.insert(applied, "Atmosphere." .. prop) end
				end
			end
			if type(args.sky) == "table" then
				local sky = Lighting:FindFirstChildOfClass("Sky")
				if not sky then sky = Instance.new("Sky"); sky.Parent = Lighting end
				for prop, val in pairs(args.sky) do
					local ok = coerceAndSet(sky, prop, val)
					if ok then table.insert(applied, "Sky." .. prop) end
				end
			end
			if type(args.effects) == "table" then
				local validEffects = { Bloom = true, ColorCorrection = true, SunRays = true, DepthOfField = true, Blur = true }
				for _, eff in ipairs(args.effects) do
					if validEffects[eff.type] then
						local className = eff.type .. "Effect"
						local existing = Lighting:FindFirstChild(className)
						if not existing or not existing:IsA(className) then
							if existing then existing:Destroy() end
							existing = Instance.new(className)
							existing.Parent = Lighting
						end
						if type(eff.properties) == "table" then
							for prop, val in pairs(eff.properties) do
								local ok = coerceAndSet(existing, prop, val)
								if ok then table.insert(applied, className .. "." .. prop) end
							end
						end
						table.insert(applied, className .. " ensured")
					end
				end
			end
		end)
		if cErr then return "ERROR setting scene mood: " .. cErr end
		if #applied == 0 then
			return "Nothing applied — pass at least one of lighting/atmosphere/sky/effects."
		end
		return "✅ Updated: " .. table.concat(applied, ", ")

		-- ── Gameplay scaffolds ────────────────────────────────────
	elseif name == "scaffold_leaderstats" then
		if type(args.stats) ~= "table" or #args.stats == 0 then
			return "ERROR: 'stats' must be a non-empty array of {name, type, default}"
		end
		local validTypes = { IntValue = true, NumberValue = true, StringValue = true, BoolValue = true }
		local stats = {}
		for i, st in ipairs(args.stats) do
			if type(st.name) ~= "string" or st.name == "" then
				return "ERROR: stats[" .. i .. "] needs a 'name'"
			end
			local className = st.type or "IntValue"
			if not validTypes[className] then
				return "ERROR: stats[" .. i .. "].type must be one of IntValue|NumberValue|StringValue|BoolValue"
			end
			local default = st.default
			if default == nil then
				if className == "StringValue" then default = ""
				elseif className == "BoolValue" then default = false
				else default = 0 end
			end
			table.insert(stats, { name = st.name, class_name = className, default = default })
		end

		local datastoreName = (type(args.datastore_name) == "string" and args.datastore_name ~= "")
			and args.datastore_name or "PlayerData_v1"
		local autosaveInterval = tonumber(args.autosave_interval) or 60
		local scriptName = (type(args.name) == "string" and args.name ~= "") and args.name or "LeaderstatsManager"
		local source = buildLeaderstatsScript(stats, datastoreName, autosaveInterval)

		local result, cErr = withWaypoint("AI scaffold leaderstats '" .. scriptName .. "'", function()
			local sss = game:GetService("ServerScriptService")
			local existing = sss:FindFirstChild(scriptName)
			if existing then existing:Destroy() end
			local s = Instance.new("Script")
			s.Name = scriptName
			s.Source = source
			s.Parent = sss
			return s
		end)
		if cErr then return "ERROR creating script: " .. cErr end

		local names = {}
		for _, s in ipairs(stats) do table.insert(names, s.name) end
		return "✅ Created " .. result:GetFullName() .. " tracking " .. #stats .. " stat(s): " ..
			table.concat(names, ", ") .. ".\nIncludes atomic UpdateAsync saves, retry+backoff, " ..
			"save-on-leave, save-on-shutdown (BindToClose), autosave every " .. autosaveInterval ..
			"s, and a basic single-session lock (35s stale-lock timeout). This is NOT a full " ..
			"session-locking library like ProfileService — swap that in for a real-money economy " ..
			"or anything where duplication would be costly."

	elseif name == "scaffold_npc_behavior" then
		local rig, rerr = resolvePath(args.rig)
		if not rig then return "ERROR resolving rig: " .. rerr end
		if not rig:IsA("Model") or not rig:FindFirstChildOfClass("Humanoid") then
			return "ERROR: 'rig' must be a Model with a Humanoid"
		end
		local mode = args.mode or "wander"
		local validModes = { patrol = true, wander = true, chase_nearest_player = true }
		if not validModes[mode] then
			return "ERROR: 'mode' must be one of patrol|wander|chase_nearest_player"
		end
		if mode == "patrol" and (type(args.waypoints) ~= "table" or #args.waypoints < 2) then
			return "ERROR: 'waypoints' (array of [x,y,z], at least 2) is required for mode=patrol"
		end

		local scriptName = (type(args.name) == "string" and args.name ~= "") and args.name or "NPCBehavior"
		local source = buildNpcScript(mode, args.waypoints, tonumber(args.wander_radius) or 20,
			tonumber(args.chase_range) or 30, args.loop ~= false)

		local result, cErr = withWaypoint("AI scaffold NPC behavior '" .. scriptName .. "'", function()
			local existing = rig:FindFirstChild(scriptName)
			if existing then existing:Destroy() end
			local s = Instance.new("Script")
			s.Name = scriptName
			s.Source = source
			s.Parent = rig
			return s
		end)
		if cErr then return "ERROR creating script: " .. cErr end
		return "✅ Created " .. result:GetFullName() .. " (" .. mode .. " mode). Uses PathfindingService " ..
			"and only moves the rig in a live server/Play session — it won't do anything in plain Edit mode."

		-- ── Surfaces & materials ──────────────────────────────────
	elseif name == "apply_surface" then
		local inst, ierr = resolvePath(args.path)
		if not inst then return "ERROR resolving path: " .. ierr end
		if not inst:IsA("BasePart") then return "ERROR: '" .. tostring(args.path) .. "' is not a BasePart" end

		local applied = {}
		local _, cErr = withWaypoint("AI apply surface to " .. inst.Name, function()
			local simpleProps = {
				material = "Material", material_variant = "MaterialVariant", color = "Color",
				transparency = "Transparency", reflectance = "Reflectance",
			}
			for argName, propName in pairs(simpleProps) do
				if args[argName] ~= nil then
					local ok = coerceAndSet(inst, propName, args[argName])
					if ok then table.insert(applied, propName) end
				end
			end

			if type(args.decals) == "table" then
				for _, d in ipairs(args.decals) do
					local faceOk, faceEnum = pcall(function() return Enum.NormalId[d.face or "Front"] end)
					if faceOk and faceEnum then
						local nm = "Decal_" .. (d.face or "Front")
						local existing = inst:FindFirstChild(nm)
						if existing then existing:Destroy() end
						local dec = Instance.new("Decal")
						dec.Name = nm
						dec.Face = faceEnum
						local tid = tostring(d.texture or "")
						if tid:match("^%d+$") then tid = "rbxassetid://" .. tid end
						dec.Texture = tid
						dec.Transparency = tonumber(d.transparency) or 0
						dec.Parent = inst
						table.insert(applied, "Decal(" .. (d.face or "Front") .. ")")
					end
				end
			end

			if type(args.texture) == "table" then
				local t = args.texture
				local faceOk, faceEnum = pcall(function() return Enum.NormalId[t.face or "Front"] end)
				if faceOk and faceEnum then
					local nm = "Texture_" .. (t.face or "Front")
					local existing = inst:FindFirstChild(nm)
					if existing then existing:Destroy() end
					local tex = Instance.new("Texture")
					tex.Name = nm
					tex.Face = faceEnum
					local tid = tostring(t.image_id or "")
					if tid:match("^%d+$") then tid = "rbxassetid://" .. tid end
					tex.Texture = tid
					tex.StudsPerTileU = tonumber(t.studs_per_tile_u) or 4
					tex.StudsPerTileV = tonumber(t.studs_per_tile_v) or 4
					tex.Parent = inst
					table.insert(applied, "Texture(" .. (t.face or "Front") .. ")")
				end
			end

			if type(args.surface_appearance) == "table" then
				if not inst:IsA("MeshPart") then
					table.insert(applied, "⚠️ surface_appearance skipped — only works on MeshPart")
				else
					local sa = inst:FindFirstChildOfClass("SurfaceAppearance")
					if not sa then sa = Instance.new("SurfaceAppearance"); sa.Parent = inst end
					local map = {
						color_map = "ColorMap", normal_map = "NormalMap",
						metalness_map = "MetalnessMap", roughness_map = "RoughnessMap", alpha_mode = "AlphaMode",
					}
					for k, propName in pairs(map) do
						if args.surface_appearance[k] ~= nil then
							local val = args.surface_appearance[k]
							if propName ~= "AlphaMode" and tostring(val):match("^%d+$") then
								val = "rbxassetid://" .. tostring(val)
							end
							local ok = coerceAndSet(sa, propName, val)
							if ok then table.insert(applied, "SurfaceAppearance." .. propName) end
						end
					end
				end
			end
		end)
		if cErr then return "ERROR applying surface: " .. cErr end
		if #applied == 0 then
			return "Nothing applied — pass material/color/decals/texture/surface_appearance."
		end
		return "✅ Applied to " .. inst:GetFullName() .. ": " .. table.concat(applied, ", ")

		-- ── Project health & testing ──────────────────────────────
	elseif name == "detect_code_style" then
		local rootPath = args.root or "game"
		local root
		if rootPath == "game" then
			root = game
		else
			local r, err = resolvePath(rootPath)
			if not r then return "ERROR resolving root: " .. err end
			root = r
		end

		local sampleLimit = math.min(tonumber(args.sample_limit) or 12, 40)
		local scripts = {}
		for _, d in ipairs(root:GetDescendants()) do
			if d:IsA("LuaSourceContainer") then
				local ok, src = pcall(function() return d.Source end)
				if ok and src and #src > 40 then
					table.insert(scripts, { inst = d, src = src })
					if #scripts >= sampleLimit then break end
				end
			end
		end
		if #scripts == 0 then
			return "No non-trivial scripts found under " .. root:GetFullName() ..
				" to sample — nothing to match yet, use your own best judgment/defaults."
		end

		local tabIndents, spaceIndents = 0, 0
		local spaceWidths = {}
		local camel, pascal, snake = 0, 0, 0
		local doubleQuotes, singleQuotes = 0, 0
		local taskWaitCount, waitCount = 0, 0
		local semicolonLines, totalLines = 0, 0

		local function classify(ident)
			if ident:find("_") and ident:match("^[a-z]") then snake = snake + 1
			elseif ident:match("^[a-z]") then camel = camel + 1
			elseif ident:match("^[A-Z]") then pascal = pascal + 1 end
		end

		for _, s in ipairs(scripts) do
			for line in s.src:gmatch("[^\r\n]+") do
				totalLines = totalLines + 1
				local leading = line:match("^(%s+)%S")
				if leading then
					if leading:sub(1, 1) == "\t" then
						tabIndents = tabIndents + 1
					else
						spaceIndents = spaceIndents + 1
						table.insert(spaceWidths, #leading)
					end
				end
				if line:match(";%s*$") then semicolonLines = semicolonLines + 1 end
			end
			for ident in s.src:gmatch("local%s+function%s+([%a_][%w_]*)") do classify(ident) end
			for ident in s.src:gmatch("local%s+([%a_][%w_]*)%s*=") do classify(ident) end
			for _ in s.src:gmatch('"') do doubleQuotes = doubleQuotes + 1 end
			for _ in s.src:gmatch("'") do singleQuotes = singleQuotes + 1 end
			for _ in s.src:gmatch("[^%w_]task%.wait%s*%(") do taskWaitCount = taskWaitCount + 1 end
			for _ in s.src:gmatch("[^%w_]wait%s*%(") do waitCount = waitCount + 1 end
		end

		local indentStyle
		if tabIndents > spaceIndents then
			indentStyle = "tabs"
		elseif spaceIndents > 0 then
			local counts = {}
			for _, w in ipairs(spaceWidths) do counts[w] = (counts[w] or 0) + 1 end
			local bestW, bestC = 4, 0
			for w, c in pairs(counts) do if c > bestC then bestW, bestC = w, c end end
			indentStyle = bestW .. "-space"
		else
			indentStyle = "unclear (no indented lines sampled)"
		end

		local namingStyle = "mixed"
		if camel >= pascal and camel >= snake and camel > 0 then namingStyle = "camelCase"
		elseif pascal >= camel and pascal >= snake and pascal > 0 then namingStyle = "PascalCase"
		elseif snake > 0 then namingStyle = "snake_case" end

		local quoteStyle = (doubleQuotes >= singleQuotes) and "double quotes (\")" or "single quotes (')"
		local waitNote = (waitCount > taskWaitCount and waitCount > 0)
			and "uses legacy wait() more than task.wait() — match that, or gently modernize if asked"
			or "uses task.wait() (or no waits sampled) — keep using task.wait(), not legacy wait()"
		local semiNote = (semicolonLines > totalLines * 0.3)
			and "this codebase uses semicolons somewhat often"
			or "semicolons are rare here, skip them"

		return string.format(
			"Sampled %d script(s) under %s (%d lines):\n" ..
				"  Indentation: %s\n" ..
				"  Naming: %s (locals/functions sampled — camel:%d pascal:%d snake:%d)\n" ..
				"  Quote style: %s (\":%d  ':%d)\n" ..
				"  Wait style: %s\n" ..
				"  Semicolons: %d/%d lines (%s)\n" ..
				"Match these conventions in any new code you write here.",
			#scripts, root:GetFullName(), totalLines, indentStyle, namingStyle, camel, pascal, snake,
			quoteStyle, doubleQuotes, singleQuotes, waitNote, semicolonLines, totalLines, semiNote
		)

		-- ── Icon library ───────────────────────────────────────────
	elseif name == "list_icon_library" then
		local lib = ensureIconLibrary()
		if #lib == 0 then
			return "Icon library is empty. Use catalog_asset_ids to look up real Roblox catalog " ..
				"info for new asset ids, then add_icon_to_library to save them."
		end
		local q = type(args.query) == "string" and args.query:lower() or nil
		local cat = type(args.category) == "string" and args.category:lower() or nil
		local lines = {}
		for _, e in ipairs(lib) do
			local matchesCat = not cat or (e.category or ""):lower() == cat
			local hay = ((e.name or "") .. " " .. (e.description or "") .. " " .. (e.category or "")):lower()
			local matchesQuery = not q or hay:find(q, 1, true)
			if matchesCat and matchesQuery then
				table.insert(lines, string.format("rbxassetid://%s — [%s] %s — %s",
					tostring(e.id), e.category or "uncategorized", e.name or "?", e.description or ""))
			end
		end
		if #lines == 0 then return "No icons matched. Library has " .. #lib .. " total entries." end
		return table.concat(lines, "\n")

	elseif name == "catalog_asset_ids" then
		if type(args.ids) ~= "table" or #args.ids == 0 then
			return "ERROR: 'ids' must be a non-empty array of asset id numbers"
		end
		local items, idList = {}, {}
		for _, id in ipairs(args.ids) do
			local n = math.floor(tonumber(id) or 0)
			table.insert(items, { itemType = "Asset", id = n })
			table.insert(idList, tostring(n))
		end

		local detailsOk, detailsResp = pcall(function()
			return HttpService:RequestAsync({
				Url = "https://catalog.roblox.com/v1/catalog/items/details",
				Method = "POST",
				Headers = { ["Content-Type"] = "application/json" },
				Body = HttpService:JSONEncode({ items = items }),
			})
		end)
		local thumbOk, thumbResp = pcall(function()
			return HttpService:RequestAsync({
				Url = "https://thumbnails.roblox.com/v1/assets?assetIds=" .. table.concat(idList, ",") ..
					"&size=150x150&format=Png&isCircular=false",
				Method = "GET",
			})
		end)

		local detailsByID, thumbsByID = {}, {}
		if detailsOk and detailsResp.Success then
			local ok2, decoded = pcall(function() return HttpService:JSONDecode(detailsResp.Body) end)
			if ok2 and type(decoded) == "table" and type(decoded.data) == "table" then
				for _, item in ipairs(decoded.data) do detailsByID[tostring(item.id)] = item end
			end
		end
		if thumbOk and thumbResp.Success then
			local ok3, decoded = pcall(function() return HttpService:JSONDecode(thumbResp.Body) end)
			if ok3 and type(decoded) == "table" and type(decoded.data) == "table" then
				for _, item in ipairs(decoded.data) do thumbsByID[tostring(item.targetId)] = item.imageUrl end
			end
		end

		local lines = {}
		for _, key in ipairs(idList) do
			local d = detailsByID[key]
			if d then
				table.insert(lines, string.format(
					"rbxassetid://%s — \"%s\" (%s) by %s%s%s",
					key, tostring(d.name), tostring(d.assetType or d.itemType or "?"),
					tostring(d.creatorName or "unknown"),
					(type(d.description) == "string" and d.description ~= "") and ("\n  Description: " .. d.description) or "",
					thumbsByID[key] and ("\n  Thumbnail: " .. thumbsByID[key]) or ""
					))
			else
				table.insert(lines, "rbxassetid://" .. key .. " — no catalog details found (the " ..
					"items/details API mainly covers avatar wearables, not plain Decal/Image assets " ..
					"like icons — that's expected, not an error). " ..
					(thumbsByID[key] and ("Thumbnail: " .. thumbsByID[key]) or "No thumbnail either — double-check the id is real."))
			end
		end
		return table.concat(lines, "\n\n") ..
			"\n\nThis is real Roblox data, not a guess — but for plain icon/decal assets the name is " ..
			"often terse (e.g. just \"cancel\") and description is frequently empty. Look at the " ..
			"thumbnail (or ask the user) for the actual visual, then write your own one-line " ..
			"description when calling add_icon_to_library — don't invent one from the name alone."

	elseif name == "add_icon_to_library" then
		if args.id == nil then return "ERROR: 'id' (asset id number) is required" end
		local id = math.floor(tonumber(args.id) or 0)
		if id == 0 then return "ERROR: 'id' must be a valid asset id number" end
		if type(args.description) ~= "string" or args.description == "" then
			return "ERROR: 'description' is required — a short visual description (e.g. 'red X close button')"
		end

		local lib = ensureIconLibrary()
		for i, e in ipairs(lib) do
			if tostring(e.id) == tostring(id) then table.remove(lib, i); break end
		end
		table.insert(lib, {
			id = id,
			name = (type(args.name) == "string" and args.name ~= "") and args.name or tostring(id),
			description = args.description,
			category = (type(args.category) == "string" and args.category ~= "") and args.category or "uncategorized",
			source = (type(args.source) == "string" and args.source ~= "") and args.source or "manual",
		})
		saveIconLibrary(lib)
		return "✅ Added rbxassetid://" .. id .. " to the icon library (category: " ..
			((type(args.category) == "string" and args.category ~= "") and args.category or "uncategorized") ..
			"). " .. #lib .. " total entries now."

	elseif name == "remove_icon_from_library" then
		if args.id == nil then return "ERROR: 'id' is required" end
		local idStr = tostring(math.floor(tonumber(args.id) or 0))
		local lib = ensureIconLibrary()
		local removed = false
		for i, e in ipairs(lib) do
			if tostring(e.id) == idStr then table.remove(lib, i); removed = true; break end
		end
		if not removed then return "No entry with id " .. idStr .. " found in the library." end
		saveIconLibrary(lib)
		return "✅ Removed rbxassetid://" .. idStr .. " from the icon library."

	elseif name == "use_library_icon" then
		local lib = ensureIconLibrary()
		local entry
		for _, e in ipairs(lib) do
			if (args.id ~= nil and tostring(e.id) == tostring(args.id))
				or (type(args.name) == "string" and args.name ~= "" and e.name == args.name) then
				entry = e
				break
			end
		end
		if not entry then
			return "ERROR: no library entry matches id/name '" .. tostring(args.id or args.name) ..
				"'. Call list_icon_library to see what's available."
		end

		local parent, perr = resolvePath(args.parent)
		if not parent then return "ERROR resolving parent: " .. perr end
		local className = args.class or "ImageLabel"
		if className ~= "ImageLabel" and className ~= "ImageButton" and className ~= "Decal" then
			return "ERROR: 'class' must be one of ImageLabel|ImageButton|Decal"
		end

		local result, cErr = withWaypoint("AI use library icon '" .. entry.name .. "'", function()
			local img = Instance.new(className)
			img.Name = (type(args.instance_name) == "string" and args.instance_name ~= "") and args.instance_name or entry.name
			if className == "Decal" then
				img.Texture = "rbxassetid://" .. tostring(entry.id)
				local faceOk, faceEnum = pcall(function() return Enum.NormalId[args.face or "Front"] end)
				if faceOk and faceEnum then img.Face = faceEnum end
			else
				img.Image = "rbxassetid://" .. tostring(entry.id)
				img.BackgroundTransparency = 1
				-- Tiling background mode: for textures (studs/carbon-fiber/hexagon).
				-- Tiles the image instead of stretching it, fills the parent by
				-- default, and lets a background color show through a transparent
				-- texture (e.g. studs) when background_color is given.
				if args.tile then
					img.ScaleType = Enum.ScaleType.Tile
					local ts = (type(args.tile_size) == "table" and #args.tile_size >= 2) and args.tile_size or {64, 64}
					img.TileSize = UDim2.new(0, ts[1], 0, ts[2])
					if type(args.background_color) == "table" and #args.background_color >= 3 then
						img.BackgroundColor3 = Color3.fromRGB(args.background_color[1], args.background_color[2], args.background_color[3])
						img.BackgroundTransparency = 0
					end
					if type(args.size) == "table" and #args.size >= 2 then
						img.Size = UDim2.new(0, args.size[1], 0, args.size[2])
					else
						img.Size = UDim2.fromScale(1, 1)  -- fill the parent panel
					end
				elseif type(args.size) == "table" and #args.size >= 2 then
					img.Size = UDim2.new(0, args.size[1], 0, args.size[2])
				else
					img.Size = UDim2.new(0, 32, 0, 32)
				end
			end
			img.Parent = parent
			return img
		end)
		if cErr then return "ERROR creating icon instance: " .. cErr end
		return "✅ Created " .. result:GetFullName() .. " using rbxassetid://" .. entry.id ..
			" (" .. entry.description .. ")"

	elseif name == "sync_shared_icons" then
		local ok, resp = pcall(function()
			return HttpService:RequestAsync({
				Url = FIRESTORE_BASE .. "/icons?pageSize=300",
				Method = "GET",
			})
		end)
		if not ok then return "ERROR fetching shared icons: " .. tostring(resp) end
		if not resp.Success then
			return "ERROR fetching shared icons: HTTP " .. tostring(resp.StatusCode) .. " " .. tostring(resp.StatusMessage)
		end
		local decodeOk, decoded = pcall(function() return HttpService:JSONDecode(resp.Body) end)
		if not decodeOk then return "ERROR: shared icon response wasn't valid JSON" end

		local docs = decoded.documents
		if type(docs) ~= "table" or #docs == 0 then
			return "Shared library is empty (or not set up yet) — nothing to sync."
		end

		local lib = ensureIconLibrary()
		local existingByID = {}
		for i, e in ipairs(lib) do existingByID[tostring(e.id)] = i end

		local added, updated = 0, 0
		for _, doc in ipairs(docs) do
			local entry = fromFirestoreFields(doc.fields)
			if entry.id ~= nil and type(entry.description) == "string" then
				entry.source = "shared"
				local key = tostring(entry.id)
				if existingByID[key] then
					lib[existingByID[key]] = entry
					updated = updated + 1
				else
					table.insert(lib, entry)
					existingByID[key] = #lib
					added = added + 1
				end
			end
		end
		saveIconLibrary(lib)
		return "✅ Synced shared icon library: " .. added .. " new, " .. updated ..
			" updated. " .. #lib .. " total entries now (including your own manual/catalog ones)."

	elseif name == "submit_icon_suggestion" then
		if args.id == nil then return "ERROR: 'id' (asset id number) is required" end
		local id = math.floor(tonumber(args.id) or 0)
		if id == 0 then return "ERROR: 'id' must be a valid asset id number" end
		if type(args.description) ~= "string" or args.description == "" or #args.description >= 200 then
			return "ERROR: 'description' is required, a string, and under 200 characters"
		end

		local payload = {
			id = id,
			description = args.description,
			name = (type(args.name) == "string" and args.name ~= "") and args.name or tostring(id),
			category = (type(args.category) == "string" and args.category ~= "") and args.category or "uncategorized",
		}
		local ok, resp = pcall(function()
			return HttpService:RequestAsync({
				Url = FIRESTORE_BASE .. "/submissions",
				Method = "POST",
				Headers = { ["Content-Type"] = "application/json" },
				Body = HttpService:JSONEncode({ fields = toFirestoreFields(payload) }),
			})
		end)
		if not ok then return "ERROR submitting icon: " .. tostring(resp) end
		if not resp.Success then
			return "ERROR submitting icon: HTTP " .. tostring(resp.StatusCode) .. " " .. tostring(resp.StatusMessage) ..
				" — check the Firestore rules allow create on /submissions with these exact field types."
		end
		return "✅ Submitted rbxassetid://" .. id .. " to the shared inbox for review. It won't show up " ..
			"in list_icon_library or sync_shared_icons until the project owner approves it into /icons — " ..
			"submissions can't be read back by design (write-only inbox)."

	elseif name == "describe_icon" then
		-- Calls the companion AI describer server (app.py): it fetches the
		-- asset's real Roblox thumbnail, asks a free vision model what it
		-- actually depicts, auto-stores the result in the shared Firestore
		-- /icons collection, and returns it. We also merge it straight into
		-- the LOCAL library so it's usable this session without a separate
		-- sync_shared_icons round-trip.
		local base = type(CONFIG.DESCRIBER_URL) == "string" and CONFIG.DESCRIBER_URL:gsub("%s+$", "") or ""
		if base == "" then
			return "ERROR: the icon describer server isn't configured. Set CONFIG.DESCRIBER_URL " ..
				"(or the 'describer_url' plugin setting) to your deployed app.py base URL, e.g. " ..
				"https://your-app.onrender.com"
		end
		base = base:gsub("/+$", "")  -- tolerate a trailing slash in the configured URL
		if args.id == nil then return "ERROR: 'id' (asset id number) is required" end
		local id = math.floor(tonumber(args.id) or 0)
		if id == 0 then return "ERROR: 'id' must be a valid asset id number" end

		local payload = { id = id, force = args.force == true }
		if type(args.category) == "string" and args.category ~= "" then
			payload.category = args.category
		end

		local ok, resp = pcall(function()
			return HttpService:RequestAsync({
				Url = base .. "/describe",
				Method = "POST",
				Headers = { ["Content-Type"] = "application/json" },
				Body = HttpService:JSONEncode(payload),
			})
		end)
		if not ok then return "ERROR contacting describer server: " .. tostring(resp) end

		local decodeOk, decoded = pcall(function() return HttpService:JSONDecode(resp.Body) end)
		if not decodeOk or type(decoded) ~= "table" then
			return "ERROR: describer server returned non-JSON (HTTP " .. tostring(resp.StatusCode) ..
				"): " .. tostring(resp.Body):sub(1, 200)
		end
		if not resp.Success then
			return "ERROR from describer server (HTTP " .. tostring(resp.StatusCode) .. "): " ..
				(type(decoded.error) == "string" and decoded.error or tostring(resp.StatusMessage))
		end
		if type(decoded.description) ~= "string" or decoded.description == "" then
			return "ERROR: describer server response had no description field."
		end

		-- Merge the result into the local library (replace any existing entry).
		local lib = ensureIconLibrary()
		for i, e in ipairs(lib) do
			if tostring(e.id) == tostring(id) then table.remove(lib, i); break end
		end
		local entry = {
			id = id,
			name = (type(decoded.name) == "string" and decoded.name ~= "") and decoded.name or tostring(id),
			description = decoded.description,
			category = (type(decoded.category) == "string" and decoded.category ~= "") and decoded.category or "uncategorized",
			source = "ai_described",
		}
		table.insert(lib, entry)
		saveIconLibrary(lib)

		local cachedNote = decoded.cached == true and " (served from the shared cache — no model call)" or " (freshly described via " .. tostring(decoded.model or "vision model") .. ")"
		return "✅ Described rbxassetid://" .. id .. cachedNote .. ":\n  [" .. entry.category ..
			"] " .. entry.name .. " — " .. entry.description ..
			"\nStored in the shared library AND merged into your local one, so use_library_icon " ..
			"can place it right away."

	elseif name == "audit_project" then
		local rootPath = args.root or "game"
		local root
		if rootPath == "game" then
			root = game
		else
			local r, err = resolvePath(rootPath)
			if not r then return "ERROR resolving root: " .. err end
			root = r
		end

		local report = {}
		local function add(line) table.insert(report, line) end

		local ws = game:GetService("Workspace")
		local unanchored, totalParts = 0, 0
		for _, d in ipairs(ws:GetDescendants()) do
			if d:IsA("BasePart") then
				totalParts = totalParts + 1
				if not d.Anchored then unanchored = unanchored + 1 end
			end
		end
		add(string.format("Workspace: %d parts, %d unanchored.%s", totalParts, unanchored,
			unanchored > 500 and " ⚠️ High unanchored count can hurt physics performance." or ""))

		local emptyScripts, disabledScripts, deprecatedWait, allScripts = {}, {}, {}, {}
		for _, d in ipairs(root:GetDescendants()) do
			if d:IsA("LuaSourceContainer") then
				table.insert(allScripts, d)
				local ok, src = pcall(function() return d.Source end)
				if ok then
					if src:match("^%s*$") then table.insert(emptyScripts, d:GetFullName()) end
					if src:match("[^%w_]wait%s*%(") or src:match("^wait%s*%(") then
						table.insert(deprecatedWait, d:GetFullName())
					end
				end
				if (d:IsA("Script") or d:IsA("LocalScript")) and d.Disabled then
					table.insert(disabledScripts, d:GetFullName())
				end
			end
		end
		add(string.format("Scripts scanned: %d.", #allScripts))
		if #emptyScripts > 0 then add("⚠️ Empty Source: " .. table.concat(emptyScripts, ", ")) end
		if #disabledScripts > 0 then add("ℹ️ Disabled: " .. table.concat(disabledScripts, ", ")) end
		if #deprecatedWait > 0 then
			add("⚠️ Uses deprecated wait() (prefer task.wait()) — " .. #deprecatedWait .. " script(s): " ..
				table.concat(deprecatedWait, ", "))
		end

		local remotes = {}
		for _, d in ipairs(root:GetDescendants()) do
			if d:IsA("RemoteEvent") or d:IsA("RemoteFunction") then table.insert(remotes, d) end
		end
		if #remotes > 0 then
			local orphaned = {}
			for _, remote in ipairs(remotes) do
				local referenced = false
				for _, scr in ipairs(allScripts) do
					if scr ~= remote then
						local ok, src = pcall(function() return scr.Source end)
						if ok and src:find(remote.Name, 1, true) then referenced = true; break end
					end
				end
				if not referenced then table.insert(orphaned, remote:GetFullName()) end
			end
			if #orphaned > 0 then
				add("⚠️ Remotes never referenced by name in any script's source (likely orphaned, " ..
					"or referenced dynamically): " .. table.concat(orphaned, ", "))
			else
				add("Remotes: " .. #remotes .. " found, all referenced somewhere.")
			end
		end

		local sg = game:GetService("StarterGui")
		local maxDepth, deepestPath = 0, nil
		local function walkDepth(inst, depth)
			if depth > maxDepth then maxDepth = depth; deepestPath = inst:GetFullName() end
			for _, c in ipairs(inst:GetChildren()) do walkDepth(c, depth + 1) end
		end
		walkDepth(sg, 0)
		add(string.format("StarterGui max nesting depth: %d%s", maxDepth,
			maxDepth > 12 and (" ⚠️ deep nesting at " .. tostring(deepestPath) .. " — consider flattening.") or ""))

		return table.concat(report, "\n")

	elseif name == "run_tests" then
		local folderPath = args.folder or "ServerScriptService.Tests"
		local folder, ferr = resolvePath(folderPath)
		if not folder then return "ERROR resolving folder: " .. ferr end

		local modules = {}
		for _, d in ipairs(folder:GetDescendants()) do
			if d:IsA("ModuleScript") then table.insert(modules, d) end
		end
		if #modules == 0 then
			return "No ModuleScripts found under " .. folder:GetFullName() ..
				". Each test module should return either a single function, or a table of " ..
				"{ name = function() ... assert()/error() on failure ... end }."
		end

		local results = {}
		local passCount, failCount = 0, 0
		local function record(failed, line)
			if failed then failCount = failCount + 1 else passCount = passCount + 1 end
			table.insert(results, { failed = failed, line = line })
		end

		for _, mod in ipairs(modules) do
			local reqOk, exported = pcall(require, mod)
			if not reqOk then
				record(true, "❌ " .. mod:GetFullName() .. " (require failed): " .. tostring(exported))
			elseif type(exported) == "function" then
				local runOk, err = pcall(exported)
				if runOk then
					record(false, "✅ " .. mod:GetFullName())
				else
					record(true, "❌ " .. mod:GetFullName() .. ": " .. tostring(err))
				end
			elseif type(exported) == "table" then
				for testName, fn in pairs(exported) do
					if type(fn) == "function" then
						local runOk, err = pcall(fn)
						if runOk then
							record(false, "✅ " .. mod:GetFullName() .. "." .. testName)
						else
							record(true, "❌ " .. mod:GetFullName() .. "." .. testName .. ": " .. tostring(err))
						end
					end
				end
			else
				table.insert(results, { failed = false, line = "⚠️ " .. mod:GetFullName() ..
					" — doesn't return a function or table of functions, skipped" })
			end
		end

		table.sort(results, function(a, b)
			if a.failed ~= b.failed then return a.failed end
			return a.line < b.line
		end)
		local lines = {}
		for _, r in ipairs(results) do table.insert(lines, r.line) end

		return string.format("%d passed, %d failed.\n\n%s", passCount, failCount, table.concat(lines, "\n"))

		-- ── Rig animation ────────────────────────────────────────
	elseif name == "spawn_rig" then
		local ok, ws = pcall(function() return game:GetService("Workspace") end)
		if not ok or not ws then return "ERROR: Workspace unavailable" end

		local folder = ws:FindFirstChild(CONFIG.RIG_FOLDER_NAME)
		if folder and not folder:IsA("Folder") then
			return "ERROR: Workspace." .. CONFIG.RIG_FOLDER_NAME .. " exists but is a " ..
				folder.ClassName .. ", not a Folder"
		end
		if not folder then
			local cok, made = pcall(function()
				local f = Instance.new("Folder")
				f.Name = CONFIG.RIG_FOLDER_NAME
				f.Parent = ws
				return f
			end)
			if not cok then return "ERROR: could not create '" .. CONFIG.RIG_FOLDER_NAME .. "': " .. tostring(made) end
			folder = made
		end

		local rigName = (type(args.name) == "string" and args.name ~= "") and args.name or "Rig"
		if folder:FindFirstChild(rigName) then
			return "ERROR: '" .. rigName .. "' already exists in Workspace." .. CONFIG.RIG_FOLDER_NAME ..
				". Pick a different name, or delete_instance the old one first."
		end

		local slotIndex = 0
		for _, c in ipairs(folder:GetChildren()) do
			if c:IsA("Model") then slotIndex = slotIndex + 1 end
		end

		local result, sErr = withWaypoint("AI spawn rig '" .. rigName .. "'", function()
			local model, berr = buildRigModel(args.rig_type or CONFIG.RIG_TYPE, rigName, folder, slotIndex)
			if not model then error(berr, 0) end
			Selection:Set({ model })
			return model
		end)
		if sErr then return "ERROR spawning rig: " .. sErr end
		return "✅ Spawned " .. (args.rig_type or CONFIG.RIG_TYPE) .. " rig at " .. result:GetFullName() ..
			". Call list_rig_joints on it to see posable part names."

	elseif name == "list_rig_joints" then
		local rig, rerr = resolvePath(args.rig or args.path)
		if not rig then return "ERROR resolving rig: " .. rerr end
		local parentOf, partByName, root = buildRigJointMap(rig)
		if not root then
			return "ERROR: no Motor6D joints found under " .. rig:GetFullName() ..
				". This needs a rig with Motor6D joints (e.g. a Humanoid character, R15/R6)."
		end
		local childrenOf = {}
		for child, parent in pairs(parentOf) do
			childrenOf[parent] = childrenOf[parent] or {}
			table.insert(childrenOf[parent], child)
		end
		local lines = { "Root: " .. root, "" }
		local function walk(nm, depth)
			table.insert(lines, string.rep("  ", depth) .. nm)
			local kids = childrenOf[nm]
			if kids then
				table.sort(kids)
				for _, k in ipairs(kids) do walk(k, depth + 1) end
			end
		end
		walk(root, 0)
		table.insert(lines, "")
		table.insert(lines, "Pass any of these part names as keys in create_rig_animation's " ..
			"\"poses\" object.")
		return table.concat(lines, "\n")

	elseif name == "create_rig_animation" then
		local rig, rerr = resolvePath(args.rig)
		if not rig then return "ERROR resolving rig: " .. rerr end
		if not rig:IsA("Model") then return "ERROR: 'rig' must be a Model" end
		if type(args.keyframes) ~= "table" or #args.keyframes == 0 then
			return "ERROR: 'keyframes' must be a non-empty array of {time, poses}"
		end
		local animName = (type(args.name) == "string" and args.name ~= "") and args.name or "AIAnimation"

		local parentOf, partByName, root = buildRigJointMap(rig)
		if not root then
			return "ERROR: could not find a root joint in " .. rig:GetFullName() ..
				" — make sure the rig has Motor6D joints. Use list_rig_joints to check first."
		end

		local out, buildErr = withWaypoint("AI create animation '" .. animName .. "'", function()
			local seq = Instance.new("KeyframeSequence")
			seq.Name = animName
			seq.Loop = args.loop == true
			if type(args.priority) == "string" then
				local ok, pr = pcall(function() return Enum.AnimationPriority[args.priority] end)
				if ok and pr then seq.Priority = pr end
			end

			local skippedAll = {}
			for _, kf in ipairs(args.keyframes) do
				local keyframe = Instance.new("Keyframe")
				keyframe.Time = tonumber(kf.time) or 0
				local poseSpecs = {}
				if type(kf.poses) == "table" then
					for partName, spec in pairs(kf.poses) do
						if partByName[partName] then
							poseSpecs[partName] = spec
						else
							table.insert(skippedAll, partName)
						end
					end
				end
				local tree, skipped = buildPoseTree(parentOf, root, poseSpecs)
				for _, s in ipairs(skipped) do table.insert(skippedAll, s) end
				instantiatePoseTree(tree, keyframe)
				keyframe.Parent = seq
			end

			local animsFolder = rig:FindFirstChild("Animations")
			if not animsFolder or not animsFolder:IsA("Folder") then
				animsFolder = Instance.new("Folder")
				animsFolder.Name = "Animations"
				animsFolder.Parent = rig
			end
			local existing = animsFolder:FindFirstChild(animName)
			if existing then existing:Destroy() end
			seq.Parent = animsFolder

			return { seqPath = seq:GetFullName(), skipped = skippedAll, kfCount = #args.keyframes }
		end)
		if buildErr then return "ERROR building animation: " .. buildErr end

		local msg = "✅ Created KeyframeSequence '" .. animName .. "' (" .. out.kfCount ..
			" keyframe(s)) at " .. out.seqPath ..
			".\nPreview it with play_rig_animation, or publish it normally via Studio's " ..
			"Animation Editor (Plugins → Animation Editor → Edit Existing Animation → " ..
			"point at this KeyframeSequence)."
		if #out.skipped > 0 then
			msg = msg .. "\n⚠️ Skipped unknown part name(s): " .. table.concat(out.skipped, ", ") ..
				" — call list_rig_joints to see valid names."
		end
		return msg

	elseif name == "play_rig_animation" then
		local rig, rerr = resolvePath(args.rig)
		if not rig then return "ERROR resolving rig: " .. rerr end
		local seq, serr = resolvePath(args.animation)
		if not seq then return "ERROR resolving animation: " .. serr end
		if not seq:IsA("KeyframeSequence") then
			return "ERROR: '" .. tostring(args.animation) .. "' is not a KeyframeSequence"
		end

		local clipOk, clipId = pcall(function()
			return game:GetService("AnimationClipProvider"):RegisterAnimationClip(seq)
		end)
		if not clipOk or not clipId or clipId == "" then
			return "ERROR: could not register the animation for live preview (" .. tostring(clipId) ..
				"). Open it in Studio's Animation Editor instead (Edit Existing Animation → " ..
				seq:GetFullName() .. ")."
		end

		local animatorOwner
		local humanoid = rig:FindFirstChildOfClass("Humanoid")
		if humanoid then
			animatorOwner = humanoid
		else
			animatorOwner = rig:FindFirstChildOfClass("AnimationController")
			if not animatorOwner then
				animatorOwner = Instance.new("AnimationController")
				animatorOwner.Parent = rig
			end
		end
		local animator = animatorOwner:FindFirstChildOfClass("Animator")
		if not animator then
			animator = Instance.new("Animator")
			animator.Parent = animatorOwner
		end

		local anim = Instance.new("Animation")
		anim.Name = seq.Name
		anim.AnimationId = clipId

		local playOk, trackOrErr = pcall(function()
			local track = animator:LoadAnimation(anim)
			track.Looped = seq.Loop
			track:Play()
			return track
		end)
		if not playOk then return "ERROR playing animation: " .. tostring(trackOrErr) end
		return "▶️ Playing '" .. seq.Name .. "' on " .. rig:GetFullName() ..
			" (live preview only — not a saved/published animation asset)."

		-- ── UI animation ───────────────────────────────────────
	elseif name == "tween_ui" then
		local inst, ierr = resolvePath(args.path)
		if not inst then return "ERROR resolving path: " .. ierr end
		if not inst:IsA("GuiObject") then
			return "ERROR: '" .. tostring(args.path) .. "' is not a GuiObject"
		end
		if type(args.properties) ~= "table" or next(args.properties) == nil then
			return "ERROR: 'properties' must be a non-empty object of target values"
		end

		local props = {}
		for prop, val in pairs(args.properties) do
			local coerced = val
			if type(val) == "string" then
				coerced = parseValueString(val)
			elseif type(val) == "table" then
				if #val == 4 then coerced = UDim2.new(val[1], val[2], val[3], val[4])
				elseif #val == 3 and prop:find("Color") then coerced = Color3.fromRGB(val[1], val[2], val[3])
				elseif #val == 3 then coerced = Vector3.new(val[1], val[2], val[3])
				elseif #val == 2 then coerced = Vector2.new(val[1], val[2])
				end
			end
			props[prop] = coerced
		end

		local duration = tonumber(args.duration) or 0.3
		local styleOk, style = pcall(function() return Enum.EasingStyle[args.easing_style or "Quad"] end)
		local dirOk, direction = pcall(function() return Enum.EasingDirection[args.easing_direction or "Out"] end)
		if not styleOk or not style then return "ERROR: invalid easing_style '" .. tostring(args.easing_style) .. "'" end
		if not dirOk or not direction then return "ERROR: invalid easing_direction '" .. tostring(args.easing_direction) .. "'" end
		local repeatCount = math.floor(tonumber(args.repeat_count) or 0)
		local reverses = args.reverses == true
		local delayTime = tonumber(args.delay) or 0

		local TweenService = game:GetService("TweenService")
		local infinite = repeatCount < 0
		local totalTime = delayTime + duration * (repeatCount + 1) * (reverses and 2 or 1)
		local tween

		local _, tErr = withWaypoint("AI tween UI " .. inst.Name, function()
			local info = TweenInfo.new(duration, style, direction, repeatCount, reverses, delayTime)
			tween = TweenService:Create(inst, info, props)
			tween:Play()
			if not infinite and totalTime <= 15 then
				tween.Completed:Wait()
			end
		end)
		if tErr then return "ERROR tweening: " .. tErr end

		if infinite then
			return "▶️ Started an infinite tween on " .. inst:GetFullName() ..
				" (repeat_count was negative, so it loops forever). Preview-only — it will keep " ..
				"running until you close/reload the place. Use create_ui_animation_script to make " ..
				"this a real part of the game."
		elseif totalTime > 15 then
			return "▶️ Started a " .. string.format("%.1f", totalTime) .. "s tween on " .. inst:GetFullName() ..
				" — it's still playing in the background (too long to wait on). Properties will land " ..
				"on their target values when it finishes; Ctrl+Z reverts to the pre-tween values."
		end
		return "✅ Tweened " .. inst:GetFullName() .. " over " .. duration ..
			"s (preview applied now — properties are left at their new values; Ctrl+Z to revert). " ..
			"This only ran in Studio for preview. For it to play for real players in the published " ..
			"game, use create_ui_animation_script to generate the actual runtime script."

	elseif name == "create_ui_animation_script" then
		local target, terr = resolvePath(args.path)
		if not target then return "ERROR resolving path: " .. terr end
		if not target:IsA("GuiObject") then
			return "ERROR: '" .. tostring(args.path) .. "' is not a GuiObject (Frame/TextLabel/Button/etc.)"
		end
		if type(args.steps) ~= "table" or #args.steps == 0 then
			return "ERROR: 'steps' must be a non-empty array of {properties, duration, ...}"
		end
		local trigger = args.trigger or "appear"
		local validTriggers = { appear = true, loop = true, hover = true, click = true }
		if not validTriggers[trigger] then
			return "ERROR: 'trigger' must be one of appear|loop|hover|click"
		end

		local scriptName = (type(args.name) == "string" and args.name ~= "") and args.name or "UIAnim"
		local source, genErr = buildUiAnimScript(args.steps, trigger, args.reverse_on_leave ~= false)
		if not source then return "ERROR generating script: " .. tostring(genErr) end

		local result, cErr = withWaypoint("AI create UI animation script '" .. scriptName .. "'", function()
			local existing = target:FindFirstChild(scriptName)
			if existing then existing:Destroy() end
			local ls = Instance.new("LocalScript")
			ls.Name = scriptName
			ls.Source = source
			ls.Parent = target
			return ls
		end)
		if cErr then return "ERROR creating script: " .. cErr end

		local note = ""
		if trigger == "click" and not (target:IsA("TextButton") or target:IsA("ImageButton")) then
			note = "\n⚠️ '" .. target.ClassName .. "' has no MouseButton1Click event — click triggers " ..
				"only fire on TextButton/ImageButton. Consider overlaying one, or use trigger \"hover\" instead."
		end
		return "✅ Created " .. result:GetFullName() .. " (" .. trigger .. " trigger, " ..
			#args.steps .. " step(s)). This is real game code — it will run for actual players once " ..
			"published, no Animation Editor export needed. Use tween_ui first if you just want to " ..
			"preview the motion in Studio before committing to a script." .. note

		-- ── Snapshots & revert (code safety net) ────────────────────
	elseif name == "create_snapshot" then
		local snapName = args.name or os.date("!snap-%H%M%S")
		local root = game
		if args.path and args.path ~= "" then
			local r, perr = resolvePath(args.path)
			if not r then return "ERROR: " .. perr end
			root = r
		end
		local scripts, count, bytes = {}, 0, 0
		local function scan(inst)
			if inst:IsA("LuaSourceContainer") then
				local ok, srcText = pcall(function() return inst.Source end)
				if ok and type(srcText) == "string" then
					scripts[inst:GetFullName()] = srcText
					count = count + 1; bytes = bytes + #srcText
				end
			end
			local ok, ch = pcall(function() return inst:GetChildren() end)
			if ok then for _, c in ipairs(ch) do scan(c) end end
		end
		scan(root)
		if bytes > SNAPSHOT_MAX_BYTES then
			return "ERROR: snapshot too large (" .. math.floor(bytes / 1024) .. " KB > " .. math.floor(SNAPSHOT_MAX_BYTES / 1024) .. " KB cap). Narrow it with a 'path'."
		end
		local snaps = getSnapshots()
		snaps[snapName] = { time = os.date("!%Y-%m-%d %H:%M:%S"), scripts = scripts }
		saveSnapshots(snaps)
		return "📸 Snapshot '" .. snapName .. "' saved — " .. count .. " script(s), " .. math.floor(bytes / 1024) .. " KB. Restore one with revert_script."

	elseif name == "revert_script" then
		if type(args.path) ~= "string" or args.path == "" then return "ERROR: 'path' is required" end
		local snaps = getSnapshots()
		local snapName = args.snapshot
		if not snapName then
			local latest, latestTime = nil, ""
			for k, v in pairs(snaps) do if (v.time or "") >= latestTime then latest = k; latestTime = v.time or "" end end
			snapName = latest
		end
		if not snapName or not snaps[snapName] then return "ERROR: no snapshot found (create one with create_snapshot or /snapshot)" end
		local saved = snaps[snapName].scripts[args.path]
		if saved == nil then return "ERROR: " .. args.path .. " is not in snapshot '" .. snapName .. "'" end
		local inst, e = resolvePath(args.path)
		if not inst then return "ERROR: " .. e end
		local _, rErr = withWaypoint("AI revert " .. inst.Name, function() inst.Source = saved end)
		if rErr then return "ERROR reverting: " .. rErr end
		return "✅ Reverted " .. args.path .. " to snapshot '" .. snapName .. "'."

		-- ── Session change log ──────────────────────────────────────
	elseif name == "get_changes" then
		if #changeLog == 0 then return "No changes recorded this session." end
		local limit = math.min(tonumber(args.limit) or 40, CHANGELOG_MAX)
		local startIdx = math.max(1, #changeLog - limit + 1)
		local out = { "Changes this session (" .. #changeLog .. " total):" }
		for i = startIdx, #changeLog do
			out[#out + 1] = "  " .. changeLog[i].time .. "  " .. changeLog[i].label
		end
		return table.concat(out, "\n")

		-- ── Plans (read/generate design plans in ServerScriptService) ────────
	elseif name == "list_plans" then
		local plans, perr = ensurePlansFolder()
		if not plans then return "ERROR: " .. perr end
		local kids = plans:GetChildren()
		if #kids == 0 then
			return "No plans yet in " .. plans:GetFullName() .. ". Create one with write_plan."
		end
		local out = { "Plans in " .. plans:GetFullName() .. " (" .. #kids .. "):" }
		for _, c in ipairs(kids) do
			if c:IsA("ModuleScript") then
				local ok, srcText = pcall(function() return c.Source end)
				local n = (ok and type(srcText) == "string") and #srcText or 0
				out[#out + 1] = "  • " .. c.Name .. "  (" .. n .. " chars)"
			else
				out[#out + 1] = "  • " .. c.Name .. "  [" .. c.ClassName .. " — not a plan]"
			end
		end
		return table.concat(out, "\n")

	elseif name == "read_plan" then
		if type(args.name) ~= "string" or args.name == "" then
			return "ERROR: 'name' is required"
		end
		local plans, perr = ensurePlansFolder()
		if not plans then return "ERROR: " .. perr end
		local plan = findPlan(plans, args.name)
		if not plan then
			return "ERROR: plan '" .. args.name .. "' not found. Use list_plans to see what's available."
		end
		local ok, srcText = pcall(function() return plan.Source end)
		if not ok then return "ERROR reading plan: " .. tostring(srcText) end
		if #srcText > CONFIG.MAX_SCRIPT_CHARS then
			srcText = srcText:sub(1, CONFIG.MAX_SCRIPT_CHARS) .. "\n-- [truncated]"
		end
		return "Plan '" .. args.name .. "':\n```\n" .. srcText .. "\n```"

	elseif name == "read_system_prompt" then
		local folder, ferr = ensureSystemPromptFolder()
		if not folder then return "ERROR: " .. ferr end
		local doc = folder:FindFirstChild(CONFIG.SYSTEM_PROMPT_SCRIPT_NAME)
		if not doc or not doc:IsA("ModuleScript") then
			return "ERROR: the system prompt copy is not in " .. folder:GetFullName() ..
				" yet. It is written automatically when the plugin loads -- reload the plugin and try again."
		end
		local ok, srcText = pcall(function() return doc.Source end)
		if not ok then return "ERROR reading system prompt: " .. tostring(srcText) end
		if #srcText > CONFIG.MAX_SCRIPT_CHARS then
			srcText = srcText:sub(1, CONFIG.MAX_SCRIPT_CHARS) .. "\n-- [truncated]"
		end
		return "System prompt (live copy at " .. doc:GetFullName() .. "):\n```\n" .. srcText .. "\n```"

	elseif name == "write_plan" then
		if type(args.name) ~= "string" or args.name == "" then
			return "ERROR: 'name' is required"
		end
		local content = args.content
		if args.content_b64 then
			local ok, decoded = pcall(function() return HttpService:Base64Decode(args.content_b64) end)
			if not ok then return "ERROR: content_b64 failed to decode: " .. tostring(decoded) end
			content = decoded
		end
		if type(content) ~= "string" then
			return "ERROR: must provide 'content' (string) or 'content_b64' (base64 string)"
		end
		local plans, perr = ensurePlansFolder()
		if not plans then return "ERROR: " .. perr end
		local existing = findPlan(plans, args.name)
		if existing and args.overwrite == false then
			return "ERROR: plan '" .. args.name .. "' already exists. Pass overwrite:true to replace it, append:true to add to it, or read_plan to view it first."
		end
		local oldSource = ""
		if existing then
			local rok, rs = pcall(function() return existing.Source end)
			if rok and type(rs) == "string" then oldSource = rs end
		end
		-- Append mode: tack new content onto the existing plan instead of replacing.
		local isAppend = existing and (args.append == true or args.mode == "append")
		local finalContent = content
		if isAppend then
			local sep = (oldSource ~= "" and oldSource:sub(-1) ~= "\n") and "\n" or ""
			finalContent = oldSource .. sep .. content
		end
		-- Save the pre-edit version to durable history (undo-independent).
		if existing then recordPlanVersion(args.name, oldSource) end
		local verb = existing and "update" or "create"
		local _, werr = withWaypoint("AI " .. verb .. " plan " .. args.name, function()
			local plan = existing
			if not plan then
				plan = Instance.new("ModuleScript")
				plan.Name = args.name
				plan.Parent = plans
			end
			plan.Source = finalContent
		end)
		if werr then return "ERROR writing plan: " .. werr end
		local action = existing and (isAppend and "appended to" or "updated") or "created"
		return "✅ Plan '" .. args.name .. "' " .. action ..
			" in " .. plans:GetFullName() .. " (" .. #finalContent .. " chars, Ctrl+Z to revert)."

	elseif name == "delete_plan" then
		if type(args.name) ~= "string" or args.name == "" then
			return "ERROR: 'name' is required"
		end
		local plans, perr = ensurePlansFolder()
		if not plans then return "ERROR: " .. perr end
		local plan = findPlan(plans, args.name)
		if not plan then
			return "ERROR: plan '" .. args.name .. "' not found."
		end
		local prevSource = ""
		do
			local rok, rs = pcall(function() return plan.Source end)
			if rok and type(rs) == "string" then prevSource = rs end
		end
		recordPlanVersion(args.name, prevSource)
		local _, derr = withWaypoint("AI delete plan " .. args.name, function()
			plan:Destroy()
		end)
		if derr then return "ERROR deleting plan: " .. derr end
		return "🗑️ Plan '" .. args.name .. "' deleted (Ctrl+Z to restore, or restore_plan to bring back its last saved version)."

		-- ── Plan history: diff & restore ───────────────────────────
	elseif name == "diff_plan" then
		if type(args.name) ~= "string" or args.name == "" then
			return "ERROR: 'name' is required"
		end
		local current = nil
		local plans = getPlansFolderIfExists()
		if plans then
			local p = findPlan(plans, args.name)
			if p then
				local rok, rs = pcall(function() return p.Source end)
				if rok and type(rs) == "string" then current = rs end
			end
		end
		local hist = getPlanHistory()
		local vers = hist[args.name]
		if not vers or #vers == 0 then
			return "No saved history for plan '" .. args.name .. "' yet. A version is recorded automatically whenever you overwrite or delete a plan."
		end
		local idx = tonumber(args.version) or #vers
		if idx < 1 or idx > #vers then
			return "ERROR: version " .. tostring(idx) .. " out of range (1.." .. #vers .. ")."
		end
		local base = vers[idx]
		if current == nil then
			return "Plan '" .. args.name .. "' does not currently exist (deleted). Last saved version (" .. base.time .. "):\n```\n" .. base.source .. "\n```\nUse restore_plan to bring it back."
		end
		local body, adds, dels = diffLines(base.source, current)
		return "Diff of plan '" .. args.name .. "'  (saved " .. base.time .. " -> current)\n" ..
			"+" .. adds .. " -" .. dels .. " line(s)\n```diff\n" .. body .. "\n```"

	elseif name == "restore_plan" then
		if type(args.name) ~= "string" or args.name == "" then
			return "ERROR: 'name' is required"
		end
		local hist = getPlanHistory()
		local vers = hist[args.name]
		if not vers or #vers == 0 then
			return "ERROR: no saved history for plan '" .. args.name .. "'. Nothing to restore."
		end
		local idx = tonumber(args.version) or #vers
		if idx < 1 or idx > #vers then
			return "ERROR: version " .. tostring(idx) .. " out of range (1.." .. #vers .. ")."
		end
		local target = vers[idx]
		local plans, perr = ensurePlansFolder()
		if not plans then return "ERROR: " .. perr end
		local existing = findPlan(plans, args.name)
		-- record current state first so the restore itself is reversible
		if existing then
			local rok, rs = pcall(function() return existing.Source end)
			recordPlanVersion(args.name, (rok and type(rs) == "string") and rs or "")
		end
		local _, rerr = withWaypoint("AI restore plan " .. args.name, function()
			local plan = existing
			if not plan then
				plan = Instance.new("ModuleScript")
				plan.Name = args.name
				plan.Parent = plans
			end
			plan.Source = target.source
		end)
		if rerr then return "ERROR restoring plan: " .. rerr end
		return "✅ Plan '" .. args.name .. "' restored to the version saved " .. target.time ..
			" (" .. #target.source .. " chars, Ctrl+Z to revert)."

		-- ── Batch ────────────────────────────────────────────────
	elseif name == "batch" then
		local ops = args.ops
		if type(ops) ~= "table" or #ops == 0 then
			return "ERROR: 'ops' must be a non-empty array of {name, args} objects"
		end
		local continueOnError = args.continue_on_error == true
		local dryRun = args.dry_run == true

		-- ── Gate each high-risk sub-op before the batch runs ────
		-- Closes the side door where `batch` could wrap delete_instance / set_script_source
		-- / etc. without the top-level gate ever firing on the outer "batch" call.
		-- Skipped if the batch itself is dry_run (preview-only).
		if not dryRun then
			local denied = nil
			local denyReason = nil

			for i, op in ipairs(ops) do
				if type(op) == "table" and type(op.name) == "string" then
					local subArgs = op.args or {}
					local isHighRisk = HIGH_RISK_TOOLS[op.name]
					local isSourceWrite = (op.name == "set_property"
						and (subArgs.property == "Source" or subArgs.property == "Disabled"))

					if (isHighRisk or isSourceWrite) and not subArgs.dry_run then
						local gateName = isSourceWrite
							and ("set_property:" .. subArgs.property)
							or op.name
						if isBypassActive() then
							-- Bypass: flag as gated so recursive executeTool also skips,
							-- and the executeTool gate hook will emit the chat indicator.
							op.args = subArgs
							op.args.__gated = true
						else
							local verdict = confirmAction(gateName, subArgs)
							if verdict == "deny" then
								denied = i
								denyReason = gateName
								break
							elseif verdict == "dryrun" then
								op.args = subArgs
								op.args.dry_run = true
							else
								-- Approved in pre-flight; flag so recursive executeTool
								-- doesn't re-gate the same op.
								op.args = subArgs
								op.args.__gated = true
							end
						end
					end
				end
			end
			if denied then
				return string.format(
					"🛑 Batch aborted: user denied sub-op #%d (%s).\n"
						.. "No operations were executed.",
					denied, denyReason
				)
			end
		end

		if dryRun then
			local plan = { "DRY RUN — would execute " .. #ops .. " op(s):" }
			for i, op in ipairs(ops) do
				local argsPreview = HttpService:JSONEncode(op.args or {})
				if #argsPreview > 200 then argsPreview = argsPreview:sub(1, 200) .. "..." end
				table.insert(plan, string.format("  %d. %s(%s)", i, tostring(op.name), argsPreview))
			end
			table.insert(plan, "No changes made.")
			return table.concat(plan, "\n")
		end

		pcall(function() ChangeHistoryService:SetWaypoint("Before AI batch (" .. #ops .. " ops)") end)

		inBatch = true
		local results, stoppedAt = {}, nil
		for i, op in ipairs(ops) do
			if type(op) ~= "table" or type(op.name) ~= "string" then
				table.insert(results, string.format("  %d. ERROR: malformed op (needs 'name' string)", i))
				if not continueOnError then stoppedAt = i; break end
			elseif op.name == "batch" then
				table.insert(results, string.format("  %d. ERROR: nested batch not allowed", i))
				if not continueOnError then stoppedAt = i; break end
			else
				local opOk, opResult = pcall(executeTool, op.name, op.args or {})
				local resultStr = opOk and tostring(opResult) or ("internal error: " .. tostring(opResult))
				local short = resultStr:sub(1, 200) .. (resultStr:len() > 200 and "..." or "")
				table.insert(results, string.format("  %d. %s → %s", i, op.name, short))
				if resultStr:sub(1, 5) == "ERROR" and not continueOnError then
					stoppedAt = i; break
				end
			end
		end
		inBatch = false
		if #results > 0 then aiWaypoints = aiWaypoints + 1 end

		pcall(function() ChangeHistoryService:SetWaypoint("After AI batch") end)

		local summary
		if stoppedAt then
			summary = string.format("⚠️ Batch stopped at op #%d of %d (continue_on_error was false):",
				stoppedAt, #ops)
		else
			summary = string.format("✅ Batch completed — %d/%d op(s):", #results, #ops)
		end
		return summary .. "\n" .. table.concat(results, "\n") ..
			"\n(Single Ctrl+Z reverts the whole batch.)"

		-- ── Unknown ──────────────────────────────────────────────
	else
		return "ERROR: Unknown tool '" .. tostring(name) .. "'"
	end
end
-- ── System prompt for the AI ─────────────────────────────────
local TOOL_SYSTEM = [[
You are an AI coding assistant embedded in a Roblox Studio plugin. The plugin
gives you direct read/write access to the developer's place via callbacks.

You receive an initial *tree dump* (class + name only). To inspect anything
deeper — script source, property values, full subtree — call a tool.

CALL FORMAT — STRICT
A turn is EITHER prose (no tool call) OR exactly one tool call. Never both.

To call a tool, your ENTIRE message must be:

<tool_call>
{"name": "TOOL_NAME", "args": { ... }}
</tool_call>

No prose before. No prose after. No markdown fences (no ```json blocks).
No bare JSON object without the tags. The tags are required every time.

If you forget the <tool_call> tags, the call will NOT be detected and your
turn is treated as prose. Re-emit with the tags on the next round.

If you need to discuss tool-call syntax in prose (e.g. explaining patches
that themselves contain tool-call text), describe it without emitting a
syntactically valid call. Talk about "the tool_call tags" rather than
showing a real one.

After a tool call, stop and wait for the <tool_results> message before
continuing.

OUTPUT FORMATTING -- STRICT
Your prose is rendered as plain Markdown by the plugin. Use ONLY Markdown:
  - `backticks` for inline code / identifiers (do NOT colour them yourself)
  - ```lua fenced blocks for code (the plugin syntax-highlights these)
  - **bold**, *italic*, # headings, and - bullets
NEVER emit raw Roblox RichText or HTML tags in your prose -- no <font>,
<b>, <i>, <u>, <s>, <stroke>, <br>, or <font color="#...">. The plugin
escapes those, so they show up as literal garbage text to the user. To
colour or emphasise code, just wrap it in backticks and let the plugin
style it. Likewise never write <tool_call> or <tool_results> tags inside
ordinary prose -- those tags are ONLY for actual tool calls.

AVAILABLE TOOLS

── Reading ─────────────────────────────────────────────────
get_script_source     {"path": "ServerScriptService.MyScript"}
read_script_section   {"path": "...", "start_line": 120, "end_line": 180}
list_children         {"path": "Workspace"}
get_property          {"path": "Workspace.Part", "property": "Anchored"}
get_all_properties    {"path": "Workspace.Part"}
find_instances        {"class": "Script", "name": "Main", "partial": false}
get_selection         {}
get_recent_output     {"limit": 50}
check_ui_layout       {"path": "StarterGui.MainMenu"}
                      Actually RENDERS the target UI off-screen and reports
                      each GuiObject's real AbsoluteSize/AbsolutePosition,
                      then flags collapsed (~zero size), off-screen, and
                      parent-overflowing elements. This is how you VERIFY UI
                      you built actually lays out — GuiObjects in StarterGui
                      aren't laid out in edit mode, so this is the only way to
                      see real geometry without a playtest. Run it after
                      building any non-trivial menu/HUD. Point it at a
                      ScreenGui or any Frame/Label/Button.
get_datastore_keys    {"store_name": "PlayerData", "limit": 20}
search_scripts        {"query": "PlayerData", "regex": false, "case_insensitive": true}
                      Greps the Source of every script. Returns path:line: snippet.
                      Use this to LOCATE code before reading whole files.
get_script_diagnostics {"path": "ServerScriptService.Main"}
                      Syntax check + common-issue heuristics for one script.
scan_all_diagnostics  {"path": "ServerScriptService"}   (path optional)
                      Runs the same checks across EVERY script under path
                      (or the whole game). Use for a project-wide health pass.
lint_project          {}   (path optional)
                      Project-wide WIRING sanity pass — catches structural
                      mistakes per-script diagnostics miss: scripts placed
                      where they can't run (a LocalScript in ServerScriptService,
                      a server Script in ReplicatedStorage), LocalPlayer used
                      in a server Script, remotes called (FireServer/InvokeServer/
                      …) when no RemoteEvent/RemoteFunction exists anywhere, and
                      require() pointing at a path/module that isn't there. Run
                      it after building a SYSTEM (anything with client+server
                      scripts talking over remotes) to catch the dumb breakages
                      before the user playtests. Heuristic/structural, not
                      dynamic-logic analysis.
scaffold_game         {"genre": "tycoon"}
                      ADVISORY blueprint for a game genre (tycoon | simulator |
                      obby). Returns the proven structure — folders, scripts,
                      remotes, leaderstats/DataStore plan, build order, and what
                      the USER must supply (plot models, maps, tools). Creates
                      NOTHING; you build it with your normal tools and import
                      the user's models where the blueprint marks USER MUST
                      PROVIDE. Call this FIRST when asked to build a whole game
                      of a known genre, so you start from a sound layout instead
                      of improvising one.
save_system           {"store_name": "PlayerData", "keys": ["Coins", "Gems"],
                       "autosave_interval": 60}
                      ADVISORY: returns a battle-tested DataStore save-system
                      ModuleScript (pcall retry + backoff, session locking to
                      prevent rejoin data-wipes, autosave, BindToClose flush)
                      plus a USAGE script wiring it to leaderstats for your
                      keys. "keys" can be a list or a {name=default} map.
                      Creates NOTHING — write the module into
                      ServerScriptService.SaveSystem and add the usage Script.
                      Use this instead of hand-rolling DataStore calls — data
                      loss from naive saves is the #1 thing that breaks games.
                      Remind the user to enable Studio API access + publish the
                      place or saves error.
ui_animation          {"kind": "slide"}   (slide | button | toast | all)
                      ADVISORY: returns ready-to-use TweenService recipe code
                      for common UI motions — slide-in/out menu, button hover/
                      press bounce, and notification toast. These are RUNTIME
                      behaviors, so the snippet goes in a LocalScript wired to
                      an event; nothing animates at edit time. Creates NOTHING.
                      Use it to add polish/feedback instead of writing tweens
                      from scratch.
find_references       {"symbol": "PlayerData", "max_results": 80}
                      Word-boundary grep for a symbol across all scripts.
                      Use before renaming/refactoring to find every call site.
get_runtime_errors    {"limit": 30, "clear": false}
                      Returns runtime errors captured from Output (press
                      Play/Run first, reproduce the bug, then call this).
                      Set clear:true once you've handled them.
get_changes           {"limit": 40}
                      Lists the mutations made this session (an audit trail).

── Writing scripts ─────────────────────────────────────────
patch_script          {"path": "...", "edits": [{"find": "...", "replace": "..."}]}
                      Or with base64: {"edits": [{"find_b64": "...", "replace_b64": "..."}]}
set_script_source     {"path": "...", "source": "..."}
                      Or: {"path": "...", "source_b64": "..."}
create_script         {"parent": "ServerScriptService", "name": "X",
                       "type": "Script|LocalScript|ModuleScript", "source": "..."}
replace_in_scripts    {"find": "oldName", "replace": "newName", "regex": false,
                       "path": "ServerScriptService", "dry_run": true}
                      Project-wide find & replace across script Source. Skips any
                      file the change would break (syntax-checked first). HIGH RISK —
                      run with dry_run:true once to preview, then apply.
create_snapshot       {"name": "before-refactor", "path": "..."}   (both optional)
                      Saves current Source of all scripts under path so you can
                      restore later. Take one before risky bulk edits.
revert_script         {"path": "...", "snapshot": "before-refactor"}  (snapshot optional)
                      Restores one script's Source from a snapshot (latest if omitted).

── Mutating instances ──────────────────────────────────────
create_instance       {"parent": "Workspace", "class": "Part", "name": "P",
                       "properties": {"Anchored": true, "Size": [4,1,4]}}
set_property          {"path": "...", "property": "Color", "value": [255,0,0],
                       "type": "Color3"}
                      (type is optional: "Color3" | "BrickColor" | "Vector3"
                       | "Vector2" | "UDim2" | "Enum")
rename_instance       {"path": "...", "name": "NewName"}
move_instance         {"path": "...", "new_parent": "ServerScriptService"}
clone_instance        {"path": "...", "new_parent": "Workspace", "name": "Copy"}
delete_instance       {"path": "Workspace.Part"}
set_selection         {"paths": ["Workspace.Part"]}
insert_model          {"asset_id": 1234567, "parent": "Workspace"}
get_asset_info        {"asset_id": 1234567}
                      Look up an asset's name/type/creator BEFORE insert_model
                      to confirm the id points at what you expect.
set_properties        {"path": "...", "properties": {"Anchored": true, "Transparency": 0.5},
                       "types": {"Color": "Color3"}}
                      Set several properties in one call (one undo step).
duplicate_array       {"path": "Workspace.Pillar", "count": 10,
                       "offset": [8,0,0], "rotation": [0,0,0]}
                      Clone N copies stepped by offset/rotation (fences, stairs...).
align_instances       {"paths": ["...","..."], "axis": "x", "mode": "center"}
                      mode = min | max | center.
distribute_instances  {"paths": ["...","...","..."], "axis": "x"}
                      Space 3+ instances evenly between the outermost two.
weld_parts            {"paths": ["Base","P1","P2"], "anchor_base": true}
                      WeldConstraint all to the first part; anchors the base.
create_remote         {"name": "BuyItem", "type": "RemoteEvent",
                       "parent": "ReplicatedStorage", "with_handlers": true,
                       "params": ["itemId", "quantity"], "rate_limit": 10}
                      Creates the remote (+ optional server/client handler stubs).
                      type = RemoteEvent | RemoteFunction | BindableEvent | BindableFunction.
                      "params" (optional) names the handler's arguments instead of
                      "..." in the generated stub, so it reads like real code.
                      "rate_limit" (optional, calls/sec) adds a per-player debounce
                      in the server stub — cheap exploit protection for anything a
                      client can spam (purchases, combat, etc.).
find_by_property      {"property": "Anchored", "value": false, "class": "Part"}
                      Find instances whose property matches a value (class optional;
                      partial:true for substring match on the string form).

── Spatial queries ───────────────────────────────────────────
raycast               {"origin": [0,10,0], "direction": [0,-1,0], "max_distance": 50,
                       "ignore": ["Workspace.Baseplate"], "ignore_water": false}
                      Casts a ray (origin+direction, or pass "to": [x,y,z] instead
                      of direction) and returns the first hit: instance, position,
                      distance, surface normal, material. Use straight down
                      ([0,-1,0]) to find ground height for placing things.
find_parts_near       {"center": [0,5,0], "radius": 15, "max_results": 30}
                      Lists every part within `radius` studs of a point, nearest
                      first, with class and distance — for "what's around here"
                      before placing or moving something.

── Terrain ────────────────────────────────────────────────────
edit_terrain          {"shape": "block" | "ball" | "cylinder" | "wedge" | "clear",
                       "material": "Grass", "position": [x,y,z], "size": [x,y,z],
                       "center": [x,y,z], "radius": 10, "height": 10,
                       "rotation": [0,0,0],
                       "region": {"min": [x,y,z], "max": [x,y,z]}}
                      Fills/clears smooth terrain. block/wedge need position+size;
                      ball needs center+radius; cylinder needs position+height+radius
                      (all shapes accept optional "rotation" in degrees). clear wipes
                      everything, or just "region" if given. material is any
                      Enum.Material name (Grass, Rock, Sand, Sandstone, Concrete,
                      Ice, Snow, Mud, Water, Asphalt, Basalt, ...).

── Physics constraints ───────────────────────────────────────
create_constraint     {"type": "Hinge", "part0": "Workspace.Door", "part1": "Workspace.Frame",
                       "attachment0_offset": [0,2,0], "attachment1_offset": [0,2,0],
                       "properties": {"LimitsEnabled": true, "UpperAngle": 90, "LowerAngle": 0}}
                      Creates a *Constraint between two BaseParts (or existing
                      Attachments). type = Hinge|Spring|Rope|Rod|Weld|Ball|
                      Cylindrical|Prismatic|Universal|Torsion|LineForce|
                      AlignPosition|AlignOrientation. Auto-creates an Attachment
                      on each part at the given offset (skipped if you pass an
                      Attachment directly). "properties" sets any extra fields on
                      the constraint itself (SpringStiffness, MaxForce, etc.) using
                      the same value shapes as set_property. Remember: at least one
                      of the two parts needs Anchored = false to actually move.

── Audio ──────────────────────────────────────────────────────
create_sound          {"sound_id": "rbxassetid://9042972236", "name": "Hit",
                       "parent": "SoundService", "volume": 0.5, "looped": false,
                       "playback_speed": 1, "preview": true}
                      Creates a Sound instance. "sound_id" needs a real asset id
                      from the user or the Toolbox — there's no way to search for
                      one. Plays immediately for preview unless preview:false.
play_sound / stop_sound   {"path": "SoundService.Hit"}
                      Plays/stops an existing Sound instance on demand.

── Lighting & atmosphere ──────────────────────────────────────
set_scene_mood        {"lighting": {"ClockTime": 19, "Brightness": 1.5, "Ambient": "#333",
                                     "FogColor": "#aab", "FogEnd": 800},
                       "atmosphere": {"Density": 0.4, "Color": "#aabbcc", "Haze": 1},
                       "sky": {"SkyboxBk": "rbxassetid://..."},
                       "effects": [{"type": "Bloom", "properties": {"Intensity": 1}},
                                   {"type": "ColorCorrection", "properties": {"Saturation": -0.2}}]}
                      One call for mood/environment work. "lighting" sets any
                      Lighting property directly. "atmosphere"/"sky" create the
                      Atmosphere/Sky instance under Lighting if missing, then set
                      properties on it. "effects" ensures a *Effect post-process
                      instance exists (type = Bloom|ColorCorrection|SunRays|
                      DepthOfField|Blur) and sets its properties. All values use
                      the same shapes as set_property.

── Gameplay scaffolds ─────────────────────────────────────────
scaffold_leaderstats  {"stats": [{"name": "Coins", "type": "IntValue", "default": 0},
                                  {"name": "Level", "type": "IntValue", "default": 1}],
                       "datastore_name": "PlayerData_v1", "autosave_interval": 60,
                       "name": "LeaderstatsManager"}
                      Generates a real Script in ServerScriptService that builds a
                      leaderstats folder per player and persists it. Covers the
                      common failure modes: atomic UpdateAsync writes (no read-
                      then-write race), pcall+retry with backoff on transient
                      errors, save on PlayerRemoving AND on game:BindToClose
                      (server shutdown), a periodic autosave, and a basic single-
                      session lock stamped with game.JobId (35s staleness window
                      so a crashed server doesn't permanently strand a player's
                      data). This is NOT a full session-locking library like
                      ProfileService — say so plainly if the user is building
                      anything where duplicated currency/items would be a real
                      problem, and suggest ProfileService instead. HIGH RISK —
                      prompts first, like create_script.
scaffold_npc_behavior {"rig": "Workspace.UnlimitedAi.Guard", "mode": "patrol",
                       "waypoints": [[0,3,0], [20,3,0], [20,3,20] ], "loop": true,
                       "name": "NPCBehavior"}
                      Generates a Script (parented to the rig) that moves it with
                      PathfindingService. mode = patrol (needs "waypoints", >= 2
                      points) | wander (needs "wander_radius", default 20) |
                      chase_nearest_player (needs "chase_range", default 30 —
                      paths toward whichever player is currently closest; leaves
                      a TODO for actual attack/interaction logic, which is
                      gameplay-specific). Only moves in a live server/Play
                      session, not in plain Edit mode. HIGH RISK — prompts first.

── Surfaces & materials ───────────────────────────────────────
apply_surface         {"path": "Workspace.Wall", "material": "Wood", "color": "#8B5A2B",
                       "transparency": 0, "reflectance": 0,
                       "decals": [{"face": "Front", "texture": "rbxassetid://...", "transparency": 0}],
                       "texture": {"face": "Top", "image_id": "rbxassetid://...",
                                   "studs_per_tile_u": 4, "studs_per_tile_v": 4},
                       "surface_appearance": {"color_map": "rbxassetid://...",
                                               "normal_map": "rbxassetid://...",
                                               "metalness_map": "rbxassetid://...",
                                               "roughness_map": "rbxassetid://...",
                                               "alpha_mode": "Overlay"}}
                      One call for level-dressing a part: Material/Color/
                      Transparency/Reflectance directly on the part, Decal/Texture
                      instances on a given face (face = Top|Bottom|Front|Back|
                      Left|Right), and SurfaceAppearance maps (MeshPart only —
                      skipped with a warning otherwise). Every field is optional;
                      pass whichever ones you need.

── Icon library ───────────────────────────────────────────────
A curated library of pre-vetted Roblox icon/decal asset ids. Two layers:
a LOCAL one persisted per plugin-install (follows the user across every
place) and a SHARED one backed by Firestore that anyone running this plugin
can pull approved icons from and suggest new ones to. This is the answer to
"I can't search the Toolbox": instead of guessing an asset id, check the
library first. Seeded locally with a starter set (shop, close, confirm,
rebirth, coin, luck, leaderboard, gift icons) the user already hand-checked.
list_icon_library     {"category": "coin", "query": "border"}
                      Lists LOCAL library entries (manual + catalog + any
                      already sync'd from the shared one), optionally
                      filtered by exact category or a substring match across
                      name/description/category. Call with no args to see
                      everything. ALWAYS check this before reaching for
                      insert_model or inventing an asset id for an icon — a
                      vetted id here beats a guessed one every time.
sync_shared_icons     {}
                      Pulls the shared, project-owner-approved icon list
                      (Firestore "icons" collection — public read-only) and
                      merges it into the local library (tagged source:
                      "shared"). Run this occasionally, or whenever
                      list_icon_library comes up empty for something a lot
                      of people would plausibly have added (e.g. common
                      gameplay icons) before concluding nothing exists.
submit_icon_suggestion   {"id": 75215877767190, "description": "Yellow coin, black border, 2D style",
                       "name": "coin", "category": "coin"}
                      Suggests a new icon for the SHARED library by writing
                      to Firestore's "submissions" collection — a public,
                      write-only inbox (no one, including this plugin, can
                      read it back). The project owner reviews submissions
                      manually and promotes good ones into the real "icons"
                      collection; sync_shared_icons won't show a submission
                      until that happens, so don't expect it to appear
                      immediately. "description" must be under 200 chars.
                      Only submit ids you've actually verified (e.g. via
                      catalog_asset_ids) — this is a shared resource other
                      people's projects will end up using.
describe_icon         {"id": 75215877767190, "category": "coin", "force": false}
                      Asks the companion AI describer SERVER to look at an
                      asset's real Roblox thumbnail with a vision model and
                      write a one-line visual description, then auto-saves it
                      to the SHARED Firestore "icons" collection AND merges
                      it into the local library so use_library_icon can place
                      it immediately. This is the hands-off alternative to
                      catalog_asset_ids + add_icon_to_library: use it when
                      you have a real asset id but no good description and
                      don't want to invent one. The server caches by id, so
                      repeat calls are free; pass "force": true only to
                      regenerate a bad existing description. "category" is
                      optional. Requires the describer server to be
                      configured (CONFIG.DESCRIBER_URL / the "describer_url"
                      setting) — it returns a clear error if it isn't.
catalog_asset_ids     {"ids": [14721361339, 75215877767190]}
                      Looks up REAL Roblox data for asset ids: catalog name/
                      description/creator (catalog.roblox.com — mainly
                      covers avatar wearables, so this is often empty for
                      plain icon/decal assets, which is expected) plus a
                      thumbnail image URL (thumbnails.roblox.com — works for
                      any asset type). Use this to sanity-check an id before
                      trusting it, or to gather info on a brand-new id before
                      cataloging it. This is real data, not a vision guess —
                      if the name/description comes back terse or empty,
                      look at the thumbnail (or ask the user) rather than
                      inventing a description from the bare name.
add_icon_to_library   {"id": 75215877767190, "name": "coin", "category": "coin",
                       "description": "Yellow coin, black border, 2D style",
                       "source": "manual"}
                      Saves (or overwrites, if the id already exists) one
                      entry to the LOCAL library only. "description" is
                      required — write what it actually looks like, in your
                      own words, after checking catalog_asset_ids and/or the
                      thumbnail. Use submit_icon_suggestion instead if this
                      is good enough that other people's projects should
                      have it too.
remove_icon_from_library   {"id": 75215877767190}
                      Removes an entry from the LOCAL library by id (has no
                      effect on the shared one).
use_library_icon      {"id": 75215877767190, "parent": "StarterGui.Shop.CoinIcon",
                       "class": "ImageLabel", "size": [32, 32]}
                      Creates an ImageLabel/ImageButton/Decal using a library
                      entry's verified asset id — by "id" or "name". class
                      defaults to ImageLabel; for Decal, "face" picks which
                      side (default Front).
                      For TILING BACKGROUND textures (category "texture":
                      studs/carbon-fiber/hexagon) pass "tile": true — it sets
                      ScaleType=Tile, fills the parent (Size becomes 1,1 in
                      scale unless you give "size"), and optionally takes
                      "tile_size": [64,64] (pixel size of one tile) and
                      "background_color": [r,g,b] (shows through transparent
                      textures like studs to tint them). Make it the first
                      child of a panel so content layers on top.
                      Example: {"name": "studs", "parent": "StarterGui.Menu.Panel",
                      "tile": true, "tile_size": [48,48], "background_color": [40,40,55]}
apply_theme           {"path": "StarterGui.MainMenu", "theme": "dark"}
                      Walks a UI subtree and applies a CONSISTENT palette/font/
                      corner-radius to every GuiObject in one pass — panel
                      backgrounds, button accent color, text color, font, and
                      rounded corners. Presets: dark | light | neon | pastel.
                      Override any piece: "background"/"accent"/"text" as [r,g,b],
                      "font" (Enum.Font name), "corner_radius" (px). Active bulk
                      edit, one undo waypoint. Use it to unify a whole menu's
                      look at the end instead of styling each element by hand;
                      it skips transparent containers and image icons.

── Project health & testing ───────────────────────────────────
detect_code_style     {"root": "game", "sample_limit": 12}
                      Samples up to "sample_limit" existing scripts under
                      "root" and reports indentation (tabs vs N-space),
                      naming convention (camelCase/PascalCase/snake_case),
                      quote style, wait() vs task.wait() usage, and semicolon
                      habits. Call this once before writing significant new
                      scripts into a project that already has real code, so
                      what you write blends in instead of imposing the
                      plugin's own default style. Skip it for an empty or
                      near-empty place — nothing to match yet.
audit_project         {"root": "game"}
                      Scans the place (or a sub-tree via "root") and reports:
                      unanchored-part count in Workspace (perf risk above ~500),
                      scripts with empty Source or that are Disabled, scripts
                      still using deprecated wait() instead of task.wait(),
                      RemoteEvents/Functions whose name never appears in any
                      other script (likely orphaned — best-effort text search,
                      so dynamically-built remote names will false-positive),
                      and StarterGui's max UI nesting depth. Use this
                      proactively, not just when asked, after a big batch of
                      changes.
run_tests             {"folder": "ServerScriptService.Tests"}
                      Finds every ModuleScript under "folder", requires it, and
                      runs it as a test: a module can return a single function,
                      or a table of { testName = function() ... end }. Each test
                      should error()/assert() to signal failure. Reports a
                      pass/fail summary. Write test modules with create_script
                      first, then call this to check your own logic instead of
                      just hoping run_script looked right once. Test code runs
                      with full plugin authority — keep it to pure logic checks,
                      not anything that should only run in a live server.

── Rig animation ────────────────────────────────────────────
A default rig already exists at Workspace.<rig folder>.TestRig (auto-spawned
on load — check the welcome messages for the exact path) so you always have
something to animate without waiting on the user. "rig" also accepts
@character / @player to target the player directly (see the path-shortcuts
note below) — handy for "make a player animation" requests.
spawn_rig             {"name": "Rig2", "rig_type": "R15"}
                      Spawns another default-appearance rig (R15 or R6, no
                      asset IDs/internet needed) next to the existing one in
                      the same Workspace folder. Use this if you need a fresh
                      or second rig — TestRig is already there for the common
                      case.
list_rig_joints       {"rig": "Workspace.MyRig"}
                      Walks every Motor6D under the rig and prints the joint
                      tree (root + nested part names). ALWAYS call this before
                      create_rig_animation so you pose real part names.
create_rig_animation  {"rig": "Workspace.MyRig", "name": "Wave", "loop": false,
                       "priority": "Action",
                       "keyframes": [
                         {"time": 0.0, "poses": {}},
                         {"time": 0.5, "poses": {
                            "RightUpperArm": {"rotation": [0, 0, -120]},
                            "RightLowerArm": {"rotation": [-30, 0, 0]}
                         }},
                         {"time": 1.0, "poses": {}}
                       ]}
                      Builds a KeyframeSequence and saves it under
                      <rig>.Animations.<name>. "poses" keys are part names
                      from list_rig_joints; each value is an offset from rest:
                      "rotation": [x,y,z] in DEGREES (Euler, applied X*Y*Z) and
                      optional "position": [x,y,z] in studs. Unposed parts on
                      the chain between the root and a posed part are left at
                      rest automatically — you only need to specify the joints
                      that actually move on each keyframe. "priority" is any
                      Enum.AnimationPriority name (e.g. "Idle", "Movement",
                      "Action", "Core"). This does NOT publish a Roblox asset —
                      it's a local, editable KeyframeSequence instance.
play_rig_animation    {"rig": "Workspace.MyRig",
                       "animation": "Workspace.MyRig.Animations.Wave"}
                      Registers the KeyframeSequence for an in-Studio live
                      preview and plays it immediately on the rig via its
                      Humanoid/AnimationController — no Play-mode or asset
                      upload required. Use this to check an animation you just
                      built. To ship it for real, the user (or you, narrating
                      the steps) opens Studio's Animation Editor, chooses
                      "Edit Existing Animation", and points it at the same
                      KeyframeSequence to publish/export it normally.

── Attributes & tags ─────────────────────────────────────
get_attribute         {"path": "Workspace.Part", "name": "Owner"}
get_all_attributes    {"path": "Workspace.Part"}
set_attribute         {"path": "...", "name": "Speed", "value": 16}
                      value accepts the same strings as set_property
                      (e.g. "rgb(255,0,0)", "(0,5,0)", "Enum.Material.Neon").
set_attributes        {"path": "...", "attributes": {"Speed": 16, "Owner": "red"}}
                      Set several attributes in one call.
get_tags              {"path": "Workspace.Part"}
add_tag               {"path": "Workspace.Part", "tag": "Collectible"}
remove_tag            {"path": "Workspace.Part", "tag": "Collectible"}
find_by_tag           {"tag": "Collectible"}

── UI building ─────────────────────────────────────────────
create_ui_tree        Creates a nested GUI hierarchy in one call.
                      {"parent": "StarterGui",
                       "tree": {"class": "ScreenGui", "name": "HUD",
                                "children": [
                                  {"class": "Frame", "name": "Bar",
                                   "props": {"Size": "{1,0,0,40}",
                                             "BackgroundColor3": "rgb(40,40,50)"}}
                                ]}}
                      Value strings accepted in "props":
                        "{sx,ox,sy,oy}" → UDim2,  "{scale,offset}" → UDim
                        "rgb(r,g,b)" or "#ff8800" → Color3
                        "(x,y,z)" → Vector3,  "(x,y)" → Vector2
                        "Enum.Font.Gotham" → Enum value

describe_ui_layout    {"path": "StarterGui.HUD"}
                      Returns the hierarchy with computed AbsolutePosition
                      and AbsoluteSize, plus warnings for off-screen,
                      overflow, or zero-size elements.

apply_layout          {"path": "StarterGui.HUD.Bar",
                       "type": "vertical_list" | "horizontal_list" | "grid",
                       "padding": 8, "alignment": "center" | "start" | "end",
                       "corner_radius": 6,
                       "stroke": 1 | {"thickness": 1, "color": "#888"},
                       "inset": 12 | {"top": 8, "bottom": 8, "left": 12, "right": 12},
                       "aspect_ratio": 1.5}
                      Adds/updates UIListLayout, UIGridLayout, UIPadding,
                      UICorner, UIStroke, or UIAspectRatioConstraint.
                      Reuses existing children with matching ClassName.

── UI animation ──────────────────────────────────────────────
tween_ui              {"path": "StarterGui.HUD.Bar",
                       "properties": {"Size": "{1,0,0,60}", "BackgroundTransparency": 0.2},
                       "duration": 0.4, "easing_style": "Quad", "easing_direction": "Out",
                       "delay": 0, "repeat_count": 0, "reverses": false}
                      Plays a TweenService animation on the element RIGHT NOW
                      for an instant in-Studio preview ("repeat_count": -1 =
                      loop forever; finite tweens are waited out, so the new
                      values are committed in one undo step). Property values
                      use the same strings as set_property/create_ui_tree
                      ("{sx,ox,sy,oy}", "rgb(r,g,b)", "#hex", "(x,y,z)", etc.).
                      This is PREVIEW ONLY — it does not run for real players;
                      use create_ui_animation_script for that.
create_ui_animation_script   {"path": "StarterGui.HUD.Bar", "name": "BarAppear",
                       "trigger": "appear" | "loop" | "hover" | "click",
                       "reverse_on_leave": true,
                       "steps": [
                         {"properties": {"Size": "{1,0,0,60}"}, "duration": 0.4,
                          "easing_style": "Quad", "easing_direction": "Out", "delay": 0}
                       ]}
                      Generates a real LocalScript parented under the element
                      that plays the step sequence with TweenService — this is
                      actual game code that runs for real players once
                      published (no Animation Editor export needed). Triggers:
                      appear = runs once when the UI loads; loop = repeats
                      forever while parented; hover = MouseEnter plays it
                      forward, MouseLeave reverses it back (unless
                      reverse_on_leave is false); click = MouseButton1Click
                      plays it once (needs a TextButton/ImageButton). HIGH
                      RISK — prompts first, like create_script. Try tween_ui
                      first to nail the look, then translate the same "steps"
                      shape into this tool to make it permanent.

── Plans (persistent design notes) ───────────────────────────
Plans are saved as ModuleScripts inside ServerScriptService.UnlimitedAi.Plans
(the folder chain is created automatically when the plugin loads). Use these to
record build plans, TODOs, architecture notes, or step-by-step task plans that
persist in the place file across sessions.
list_plans            {}
                      Lists every saved plan with its name and size.
read_plan             {"name": "CombatSystem"}
                      Returns the full text of one plan.
write_plan            {"name": "CombatSystem", "content": "..."}
                      Creates the plan, or overwrites it if it already exists.
                      Pass "overwrite": false to refuse replacing an existing
                      plan. Pass "append": true to add content to the END of an
                      existing plan instead of replacing it (ideal for ticking
                      off TODOs or logging progress without resending the whole
                      plan). Long text can be sent as "content_b64" (base64).
diff_plan             {"name": "CombatSystem"}
                      Shows what changed between the plan's last saved version
                      and its current text. Pass "version": N to compare against
                      an older saved version (1 = oldest).
restore_plan          {"name": "CombatSystem"}
                      Restores a plan to its last saved version, recreating it
                      if it was deleted. Pass "version": N for an older one.
                      Versions are saved automatically on every overwrite/delete.
delete_plan           {"name": "CombatSystem"}   (HIGH RISK — prompts first)

── Your system prompt (re-readable) ─────────────────────────
A live copy of THIS system prompt is saved on load at
ServerScriptService.UnlimitedAi.SystemPrompt.Prompt (a sibling of the Plans
folder). Reread your own instructions anytime with:
read_system_prompt    {}
                      Returns the full system prompt text. Use it if you
                      lose track of your tools, rules, or formatting
                      conventions during a long session.

WHEN TO USE PLANS (proactive workflow)
For any multi-step or multi-message build, manage a plan without being asked:
  1. Before starting, write_plan a short plan — goal, ordered steps, and a
     checklist of TODOs. Keep step names concrete.
  2. As you work, read_plan to reorient, then write_plan to update it —
     mark steps done, record decisions, and note what is left.
  3. When resuming later ("read your plan and continue"), read_plan first
     and pick up from the next unchecked step.
Reach for plans automatically on anything that spans several tools or
sessions; for trivial one-off edits you can skip them.

── Execution & batching ────────────────────────────────────
run_script            {"code": "return game.Players.MaxPlayers"}
batch                 {"ops": [{"name": "create_instance", "args": {...}}, ...],
                       "continue_on_error": false}

DRY-RUN
Any mutating tool accepts "dry_run": true. The plugin reports what it WOULD do
without applying changes. Use this before complex or destructive operations.

USER CONFIRMATION GATE
These tools require user approval before executing:
  run_script, set_script_source, patch_script, create_script,
  delete_instance, insert_model, replace_in_scripts, create_remote,
  revert_script, delete_plan, create_ui_animation_script,
  scaffold_leaderstats, scaffold_npc_behavior

So does set_property when writing Source or Disabled on a script.

When you call one of these, the user sees an inline prompt with three buttons:
  • Approve    → the call runs normally
  • Dry-run    → the call returns a preview only; nothing is changed
  • Deny       → the call is aborted; you receive "🛑 Denied by user."

If you receive a deny, do not retry the same call. Ask the user what they
want changed, or propose an alternative approach. Repeated identical retries
after a deny are a bug, not persistence.

Each batch's sub-ops are gated individually. A batch with one high-risk op
will prompt once; a batch with five will prompt five times. Group related
high-risk ops together when possible so the user can review them as a unit.

The user may grant a session-wide allow for a specific tool+target. Don't
rely on this — assume every high-risk call will prompt.

PRESENT_PLAN — one approval for a whole multi-step build
present_plan          {"steps": [
                         {"tool": "create_instance", "target": "Workspace", "description": "Create the Shop folder"},
                         {"tool": "create_script", "target": "ServerScriptService", "description": "Server-side purchase handler"},
                         {"tool": "create_remote", "target": "ReplicatedStorage", "description": "BuyItem remote"}
                       ]}
                      For a build with several high-risk steps, call this
                      FIRST with the full list so the user reviews and
                      approves once instead of getting prompted on every
                      individual step. "target" for each step MUST be exactly
                      what that tool's call will put in args.path or
                      args.parent (e.g. for create_script that's the parent
                      folder, not the script's eventual full path) — only an
                      exact {tool, target} match skips that step's prompt
                      later. A step with no match, or a target you got wrong,
                      still prompts individually — that's a safe fallback,
                      not a bug. Denying the plan means stop and ask what the
                      user wants instead, same as any other deny. Skip this
                      for a single high-risk step — not worth a plan for one
                      call.

EDITING SCRIPTS — read before writing, prefer patches
1. ALWAYS read the script first with get_script_source or read_script_section.
2. Prefer patch_script for any change touching <40% of a file. Use full
   set_script_source only for new files or wholesale rewrites.
3. Patches and full writes are syntax-validated automatically — if you get an
   "ERROR: syntax error" response, fix the code and retry. Pass
   skip_validation: true only if you're intentionally writing partial code.
4. For files with many quotes/backslashes, use set_script_source with
   source_b64 (base64-encoded) instead of source.

PATCH RULES (patch_script)
- Each edit's `find` must match EXACTLY ONCE in the file. Include surrounding
  context lines if needed to make it unique.
- Whitespace and indentation must match the file exactly. Do not normalize.
- Edits apply sequentially against the working copy. Order matters — later
  edits see the result of earlier ones.
- If `find` or `replace` would contain many quotes, backslashes, or newlines
  (e.g. when patching code that itself contains tool-call examples), use
  `find_b64` and `replace_b64` with base64-encoded strings instead. This
  sidesteps all JSON escaping issues:
    {"path": "...", "edits": [
      {"find_b64": "ZnVuY3Rpb24gZm9v...", "replace_b64": "ZnVuY3Rpb24gZm9vKHgp..."}
    ]}
- On success you get a compact diff back — read it to confirm your edits
  landed correctly, without needing a follow-up get_script_source.
- If a match fails, the whole patch is rolled back (no half-applied edits).
- "matched the ORIGINAL but not after edit #N" means your edits overlap —
  combine them or reorder.

BATCHING — fewer round-trips
- Use `batch` to group independent ops in one call (e.g. creating 10 parts).
- The whole batch becomes ONE undo waypoint, so the user can revert it with
  a single Ctrl+Z.
- Default is fail-fast (stop and roll back on first error). Set
  continue_on_error: true only if ops are truly independent.
- Ops execute in order; later ops can reference instances created by earlier
  ones in the same batch.

BUILDING UI — make it look good and work on every screen
The single biggest difference between amateur and professional Roblox UI is
SCALE vs OFFSET. Follow these rules unless the user explicitly asks otherwise.
After building any non-trivial menu/HUD, VERIFY it with check_ui_layout — it
renders the UI off-screen and reports real sizes/positions plus any collapsed,
off-screen, or overflowing elements. Don't trust that it looks right just
because you set the properties; StarterGui UI isn't laid out in edit mode, so
check_ui_layout is your only way to catch a broken layout without a playtest.

1. USE SCALE, NOT OFFSET. Size and Position should use the Scale component
   (UDim2.fromScale(x, y)) so the UI adapts to every screen. Keep Offset at 0.
   If you think you need Offset, you almost always need a layout/constraint
   object instead. The one fair use of Offset is UIPadding and small fixed
   gaps. Example: button.Size = UDim2.fromScale(0.2, 0.1).
2. ANCHOR FROM THE CENTER. Set AnchorPoint = Vector2.new(0.5, 0.5) and position
   with Scale (e.g. Position = UDim2.fromScale(0.5, 0.5) to center). This makes
   the element scale/position correctly across aspect ratios (phone, tablet,
   widescreen) instead of drifting toward a corner.
3. KEEP PROPORTIONS WITH CONSTRAINTS. Add a UIAspectRatioConstraint so a panel
   or icon doesn't stretch weirdly on ultrawide/square screens (AspectRatio 1
   = square; set it to width/height for other shapes). Use UISizeConstraint to
   clamp min/max pixel size when an element must not get too small/huge.
4. LAYOUT OBJECTS OVER MANUAL POSITIONING. For any row, column, or grid of
   items, parent them under a UIListLayout (lists/stacks) or UIGridLayout
   (grids) and let it position them — never hand-place each child with Scale
   math. Set FillDirection, HorizontalAlignment/VerticalAlignment, and Padding
   (a UDim, use Scale) on the layout. Use SortOrder = LayoutOrder and set each
   child's LayoutOrder to control sequence.
5. POLISH PASS — these cheap children make UI look intentional:
   - UICorner (CornerRadius) for rounded corners — flat rectangles read as
     unfinished.
   - UIPadding so text/content isn't jammed against edges.
   - UIStroke for a clean border/outline (set Thickness + Color).
   - UIGradient for depth on backgrounds and buttons.
   Set BackgroundTransparency = 1 on containers that only group/position, so
   only the intended surfaces draw.
6. RESPONSIVE FEEDBACK. Buttons should react: on hover/press, tween Size up
   ~5-10% or shift BackgroundColor3 via TweenService, and play a subtle click
   Sound. Static buttons feel broken to players. For ready-made motion, call
   ui_animation (slide-in menu, button bounce, notification toast) and drop the
   returned LocalScript recipe in rather than writing tweens from scratch.
7. SCREENGUI SETUP. Parent UI under a ScreenGui in StarterGui (it clones to
   each player's PlayerGui). Leave ScreenInsets at its default so UI stays in
   the mobile Safe Area (clear of the notch / Dynamic Island / top bar); only
   set it to None for deliberate full-bleed backgrounds. Use DisplayOrder to
   layer multiple ScreenGuis (higher draws on top). Consider ResetOnSpawn =
   false for persistent HUDs so they survive respawns.
8. MOBILE-AWARE PLACEMENT. Keep interactive UI out of the default touch
   control zones — the movement thumbstick (bottom-left) and jump button
   (bottom-right). Top-center and the upper corners are safest for HUD
   elements. Make tap targets large enough for fingers.
9. CONSISTENCY. Reuse one small color palette and consistent corner radius /
   padding / font across the whole interface. Put buttons where players expect
   them and nest sub-menus logically. A coherent mediocre style beats a
   flashy inconsistent one. To unify a whole menu in one shot, finish with
   apply_theme on the ScreenGui/panel (presets: dark/light/neon/pastel, or
   override colors/font/corner) instead of setting palette properties element
   by element — it skips transparent containers and image icons.
10. ICONS. Pull image assets from list_icon_library / use_library_icon (or
    describe_icon for a real id) rather than inventing asset ids — see the
    icon-library tools above. If the icon you need isn't in the library, DON'T
    guess or fabricate an asset id — just ASK THE USER for a Roblox id for
    what you need (e.g. "What's the asset id for the sword icon you want? You
    can grab one from the Creator Store."). Once they give it: verify it with
    catalog_asset_ids or describe_icon, save it locally with add_icon_to_library,
    and submit it to the shared library with submit_icon_suggestion so it's
    there next time — for everyone, not just this user.
11. BACKGROUNDS. A tiling texture behind a panel is the foundation of most
    polished Roblox UIs. Use one of the VERIFIED ids below, tiled (not
    stretched). Don't invent texture ids; if you need one that isn't listed,
    ask the user for a real id or use describe_icon, then add it.

UI BACKGROUND TEXTURES (verified asset ids — use these, do NOT guess new ones)
- studs (transparent stud grid, by @xiamkyla45): rbxassetid://75879630349864
    The classic Roblox UI background. Transparent, so a BackgroundColor3 or
    UIGradient behind it shows through and tints the studs.
- carbon fiber (by @N0DripNikey): rbxassetid://7971901039
    Dark diagonal weave — good for sleek/tech panels.
- hexagon (named "grid" on the store but it's a honeycomb pattern, by
    @iamashadowguy): rbxassetid://6062528791
    Tiled hex lattice. NOTE: this is a background IMAGE, unrelated to
    UIGridLayout (the layout object that arranges children into rows/columns).

APPLYING A TILING TEXTURE — tile it, never stretch it:
  ImageLabel
    Image = "rbxassetid://75879630349864"   -- (or another verified id above)
    ScaleType = Enum.ScaleType.Tile         -- repeats the texture; do NOT leave it Stretch
    TileSize = UDim2.fromOffset(64, 64)     -- pixel size of one tile; tune to taste
    BackgroundColor3 = <panel color>        -- shows THROUGH a transparent texture (e.g. studs)
    Size = UDim2.fromScale(1, 1)            -- fill the parent panel
  Make this the BOTTOM-most child of the panel, then layer content above it.
  Add a matching UICorner to the ImageLabel if the panel has rounded corners.

ROBLOX CORE GUI (the built-in top-bar: chat, player list, backpack, etc.)
The icons Roblox shows at the top of the screen (chat, player list, backpack/
inventory, emotes, health bar) are CoreGui — owned by the engine, NOT regular
GuiObjects you can create, move, or reparent. You can't build or restyle them;
you can only TOGGLE them on/off, and only at runtime from a LocalScript (this
has no effect in edit mode — it runs when the game plays). So to control them,
write a LocalScript (e.g. in StarterPlayer.StarterPlayerScripts) using
StarterGui:SetCoreGuiEnabled:

  local StarterGui = game:GetService("StarterGui")
  -- Turn individual elements on/off:
  StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Chat, true)        -- chat icon/window
  StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.PlayerList, true)  -- player list
  StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false)   -- inventory/backpack
  StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.EmotesMenu, false) -- emotes
  StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Health, true)      -- health bar
  -- Or all at once:
  StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.All, false)

The whole top bar can also be hidden via SetCore (wrap in pcall — it can error
if called before CoreGui is ready, so retry):
  StarterGui:SetCore("TopbarEnabled", false)

Guidance:
- These are ENABLE/DISABLE only. If the user wants a CUSTOM top bar (styled
  chat button, custom inventory icon), you DISABLE the built-in one with
  SetCoreGuiEnabled and BUILD your own ImageButtons in a ScreenGui instead
  (place them top-center / upper corners per the mobile rule, use icons from
  the library). Don't claim you can recolor Roblox's native icons — you can't.
- Default to leaving Chat/PlayerList ON unless the user asks to hide them.
- Since this only takes effect at play time, tell the user it'll show when they
  playtest, not in the editor viewport.

CUSTOM TOPBAR ICONS (the row of round buttons many games show up top)
The clean row of circular icon buttons (shop, settings, inventory, etc.) at the
top of polished games is NOT Roblox's native top bar — it's custom UI the game
built, usually with the community module TopbarPlus. You CAN build this; it's
just ImageButtons in a ScreenGui, not CoreGui.
- DETECT-OR-BUILD, never force-install. First check whether the user already
  has TopbarPlus (e.g. ReplicatedStorage:FindFirstChild("Icon") or a module
  named "TopbarPlus"). If present, you may use its API. Do NOT auto-insert or
  LoadAsset a third-party module into the user's game — if they want
  TopbarPlus and don't have it, tell them to add it from the Creator Store.
- DEFAULT to hand-building with native instances (no dependency, always works):
  a ScreenGui > a Frame anchored top-center/top-right (clear of Roblox's own
  buttons on the right) with a horizontal UIListLayout > circular ImageButtons.
  Make them circles with UICorner CornerRadius = UDim.new(1, 0) on a square
  (UIAspectRatioConstraint AspectRatio = 1) button. Use icons from the library;
  add hover/press tweens (RULE 6). Consider StarterGui:SetCore("TopbarEnabled",
  ...) only if the user wants to hide Roblox's native bar.

RECIPE — custom topbar icon row (hand-built, no dependency):
  ScreenGui (in StarterGui)
   └ Frame  AnchorPoint=(1,0)  Position=UDim2.new(1,-12, 0,8)  (top-right, inset)
            Size=UDim2.fromScale(0.4,0.06)  BackgroundTransparency=1
      ├ UIListLayout  FillDirection=Horizontal  Padding=UDim.new(0,8)
      │              HorizontalAlignment=Right  VerticalAlignment=Center
      └ ImageButton (xN)  Size=UDim2.fromScale(1,1)? no — give each a fixed
            aspect: AnchorPoint as needed, then
         ├ UIAspectRatioConstraint (AspectRatio = 1)   -- keep it square
         ├ UICorner (CornerRadius = UDim.new(1,0))      -- makes it a circle
         ├ Image = icon from the library
         └ (hover/press tween on Size + a click Sound)

RECIPE — centered responsive panel (the backbone of most menus):
  ScreenGui (in StarterGui)
   └ Frame  AnchorPoint=(0.5,0.5)  Position=UDim2.fromScale(0.5,0.5)
            Size=UDim2.fromScale(0.5,0.6)  BackgroundColor3=<panel color>
      ├ UICorner (CornerRadius = UDim.new(0,12))
      ├ UIAspectRatioConstraint (AspectRatio ~1.4 so it keeps shape)
      ├ UIPadding (all sides ~UDim.new(0.03,0))
      └ <content here, laid out with a UIListLayout>

RECIPE — vertical list of buttons:
  Frame (container, BackgroundTransparency=1, sized with Scale)
   ├ UIListLayout  FillDirection=Vertical  Padding=UDim.new(0.02,0)
   │              HorizontalAlignment=Center  SortOrder=LayoutOrder
   └ TextButton (xN)  Size=UDim2.fromScale(1,0.15)  LayoutOrder=i
        ├ UICorner
        └ UIStroke
  (Don't position the buttons by hand — the UIListLayout stacks them.)

RECIPE — responsive icon grid (inventory/shop):
  ScrollingFrame (Scale-sized, AutomaticCanvasSize=Y, ScrollBarThickness small)
   ├ UIGridLayout  CellSize=UDim2.fromScale(0.3,0.3)  CellPadding=UDim2.fromScale(0.02,0.02)
   │              SortOrder=LayoutOrder
   └ <cells: ImageButton + UICorner, image from the icon library>

DEBUGGING
- After running scripts or triggering Play, call get_recent_output to see
  print/warn/error messages from the Output window. Use this instead of
  guessing whether code worked.
- For runtime crashes/exceptions specifically, prefer get_runtime_errors: ask
  the user to press Play/Run and reproduce the bug, then call it to get just
  the errors (with a per-error count). Set clear:true once you've addressed
  them so the next call only shows new errors.
- After a multi-file change or any sizable edit, run scan_all_diagnostics to
  catch syntax errors and common issues across the whole project in one pass
  before telling the user you're done.
- After building a SYSTEM with client+server scripts wired over remotes (or
  any multi-script feature), also run lint_project — it catches the wiring
  mistakes scan_all_diagnostics doesn't: a script placed where it can't run,
  remotes called that were never created, a broken require() path. These are
  the bugs that pass a syntax check but break the moment the user playtests.
- When asked to build a WHOLE GAME of a known genre (tycoon, simulator, obby),
  call scaffold_game FIRST to get the proven structure, then build from it with
  your normal tools. It's advisory (creates nothing) and flags what the user
  must provide — so ask them for those models/maps up front rather than
  improvising a layout or stalling.
- Whenever a game needs to PERSIST player data (currencies, levels, rebirths,
  obby stage), use save_system to get the vetted DataStore module rather than
  writing GetAsync/SetAsync by hand — naive saves lose data on rejoin/shutdown.
  Remind the user to enable Studio API access (Game Settings > Security) and
  publish the place, or every save errors.
- Before renaming a function/variable/remote or otherwise refactoring, call
  find_references on the symbol to find every call site, so you don't miss one.
- This is a default habit, not an optional extra: after writing or editing a
  script with real logic (not a trivial one-liner), verify it before telling
  the user it's done — write/run run_tests against it if a test module exists
  or is worth writing, and check get_recent_output/get_runtime_errors if it
  can run without Play (e.g. via run_script). Creating a script is not the
  same as it working; don't claim success on the strength of it merely
  existing.
- After a sizeable batch of structural changes (new systems, several new
  instances/scripts at once), call audit_project once before declaring
  victory — it catches unanchored-part bloat, empty/disabled scripts,
  deprecated wait(), and orphaned remotes you might otherwise miss.

WORKFLOW NOTES
- Paths use dot notation. Names with dots are not supported — use find_instances.
- Prefer find_instances over walking the tree manually.
- When inserting numeric arrays for properties (Color3/Vector3/etc), pass "type"
  so the plugin builds the right Roblox type.
- Any "path" argument also accepts @selection (the first instance the user has
  selected) so you can act on what they clicked without get_selection first.
- Any "path" argument also accepts @character / @player — the local player's
  live spawned character while testing (Play/Play Solo), or StarterPlayer's
  StarterCharacter template otherwise. Handy for "animate the player" requests:
  e.g. list_rig_joints {"rig": "@character"}.
- For bulk work prefer the dedicated tools over many single calls: duplicate_array
  for repeats, set_properties/set_attributes for multi-field writes, replace_in_scripts
  for codebase-wide edits. Take a create_snapshot before large/risky changes.
- Before insert_model, call get_asset_info on the asset_id to confirm it's the
  asset you expect (right name/type/creator). Never insert an id you haven't
  verified or the user hasn't given you.
- For icons specifically, check list_icon_library first (and sync_shared_icons
  if it's empty for something plausible) — don't guess an asset id. If what
  you need isn't there, ask the user for an id (or one they found on the
  Creator Store). Once you have a real id, the fastest path to a good entry is
  describe_icon: it has the server look at the actual thumbnail and write the
  description for you, then saves it to both the shared and local libraries.
  If the describer server isn't configured (it'll say so), fall back to
  verifying with catalog_asset_ids, then saving with add_icon_to_library
  (local) and submit_icon_suggestion (shared, if it's generally useful) so
  it's there next time — for anyone, not just this user.
- Use attributes (set_attribute) and CollectionService tags (add_tag) for
  instance metadata and grouping instead of inventing naming conventions or
  hidden value objects. find_by_tag is the idiomatic way to gather a set.
- Destructive ops create undo waypoints — the user can Ctrl+Z.
- Before writing significant new scripts into a project that already has
  real code in it, call detect_code_style once (skip it for an empty/near-
  empty place) and match its indentation, naming, quote style, and wait()
  vs task.wait() habits instead of imposing the plugin's own defaults — AI-
  written code shouldn't visually stick out from the rest of the codebase.
- For a build with several high-risk steps (multiple create_script/
  create_remote/etc. calls), call present_plan first with the full step list
  so the user approves once instead of getting prompted on every single
  step. Each step's "target" must exactly match what that tool call's
  args.path or args.parent will be — only exact matches skip their prompt.
  Skip this for a single high-risk step; it's not worth a plan for one call.
- When you have enough information to answer the user, respond in plain prose
  with no tool call.
]]

-- ── Prompt builder (flatten history into a single string) ────
local cachedTreeDump = nil

local function buildPrompt(history, includeTree)
	-- Non-destructive: never mutate `history` entries or the module-level
	-- cachedTreeDump. Trimming decisions live in LOCAL overlays so that a single
	-- oversized prompt can't permanently corrupt the cached tree dump or wipe
	-- real tool results that later (smaller) prompts would still need.
	local trimmed = {}        -- [index] = true  → render this msg as a stub
	local treeOverride = nil  -- non-nil → use this instead of cachedTreeDump

	-- Helper: assemble parts → full string given current overlay state
	local function assemble()
		local parts = { TOOL_SYSTEM, "" }

		local treeText = treeOverride or cachedTreeDump
		if includeTree == false then
			-- E2: on follow-up rounds within a turn the model already saw the full
			-- tree on round 1. Re-sending it every round is the biggest fixed token
			-- cost, so send a compact pointer instead.
			table.insert(parts, "[Game tree omitted this round to save tokens — it was "
				.. "provided at the start of this turn. Call list_children / find_instances "
				.. "to inspect current structure.]")
		elseif treeText then
			table.insert(parts, "=== CURRENT GAME TREE ===")
			table.insert(parts, treeText)
		else
			table.insert(parts, "[No tree scan yet — call list_children with a service path to explore.]")
		end

		local sel = Selection:Get()
		if #sel > 0 then
			table.insert(parts, "\n=== USER'S CURRENT SELECTION ===")
			for _, inst in ipairs(sel) do
				table.insert(parts, "[" .. inst.ClassName .. "] " .. inst:GetFullName())
			end
		end

		local notes = getProjectNotes()
		if notes ~= "" then
			table.insert(parts, "\n=== PROJECT NOTES (persistent memory — architecture, conventions, decisions) ===")
			table.insert(parts, notes)
			table.insert(parts, "Honor these notes. If the user establishes a new lasting convention or decision, suggest saving it with /notes.")
		end

		local goals = getGoals()
		if #goals > 0 then
			table.insert(parts, "\n=== STANDING GOALS (the user's checklist for this session) ===")
			local openCount = 0
			for idx, g in ipairs(goals) do
				local box = g.done and "[x]" or "[ ]"
				if not g.done then openCount = openCount + 1 end
				table.insert(parts, box .. " " .. idx .. ". " .. tostring(g.text))
			end
			if openCount > 0 then
				table.insert(parts, "Prioritize actions that complete the open ([ ]) items above. "
					.. "Don't consider the work finished while open items remain, unless the user says so.")
			else
				table.insert(parts, "All listed goals are marked done — confirm with the user before assuming more work.")
			end
		end

		local plansFolder = getPlansFolderIfExists()
		if plansFolder then
			local names = {}
			for _, c in ipairs(plansFolder:GetChildren()) do
				if c:IsA("ModuleScript") then
					local ok, src = pcall(function() return c.Source end)
					local sz = (ok and type(src) == "string") and #src or 0
					names[#names + 1] = c.Name .. " (" .. sz .. " chars)"
				end
			end
			if #names > 0 then
				table.insert(parts, "\n=== SAVED PLANS (persistent design notes you can read/update) ===")
				table.insert(parts, table.concat(names, ", "))
				table.insert(parts, "Read a relevant plan with read_plan before continuing related work, and keep it current with write_plan (use append:true to log progress).")
			end
		end

		table.insert(parts, "\n=== CONVERSATION ===")
		for i, msg in ipairs(history) do
			local role = msg.role == "user" and "User" or "Assistant"
			local content = trimmed[i]
				and "<tool_results>[trimmed earlier tool result to fit prompt budget]</tool_results>"
				or msg.content
			table.insert(parts, role .. ":\n" .. content)
		end
		table.insert(parts, "Assistant:")

		return table.concat(parts, "\n\n")
	end

	local full = assemble()
	if #full <= CONFIG.MAX_PROMPT_CHARS then return full end

	-- Step 1: trim oldest <tool_results> blocks until we fit, or run out.
	-- Flag them LOCALLY (via `trimmed`) so the real content survives in
	-- `history` for future turns.
	for i = 1, #history do
		local msg = history[i]
		if msg.role == "user"
			and msg.content:sub(1, 14) == "<tool_results>"
			and not trimmed[i]
			and not msg.content:find("%[trimmed", 1, true) then
			trimmed[i] = true
			full = assemble()
			if #full <= CONFIG.MAX_PROMPT_CHARS then
				return full
			end
		end
	end

	-- Step 2: still too big after pruning all tool results.
	-- Truncate a LOCAL copy of the tree dump (never the cached original).
	if cachedTreeDump then
		local overflow = #full - CONFIG.MAX_PROMPT_CHARS
		if #cachedTreeDump > overflow + 200 then
			treeOverride = cachedTreeDump:sub(1, #cachedTreeDump - overflow - 200)
				.. "\n[... tree truncated for prompt budget ...]"
			full = assemble()
		end
	end

	-- Step 3: if we're still too big, just return what we have.
	-- The API may complain, but at least we tried both levers.
	return full
end

-- ── SSE response parser ──────────────────────────────────────
local function parseSSE(body)
	local parts, errMsg = {}, nil
	for line in body:gmatch("[^\r\n]+") do
		if line:sub(1, 5) == "data:" then
			local chunk = line:sub(6):match("^%s*(.-)%s*$")
			if chunk and chunk ~= "" then
				local ok, frame = pcall(HttpService.JSONDecode, HttpService, chunk)
				if ok and type(frame) == "table" then
					if type(frame.delta) == "string" then
						table.insert(parts, frame.delta)
					elseif frame.error then
						errMsg = tostring(frame.error)
					end
				end
			end
		end
	end
	return table.concat(parts), errMsg
end

-- ── HTTP call to selected provider ────────────────────────
local sessionStats = { calls = 0, promptChars = 0, respChars = 0 }

local function parseOpenAIChatResponse(body)
	local ok, obj = pcall(HttpService.JSONDecode, HttpService, body or "")
	if not ok or type(obj) ~= "table" then
		return nil, "could not parse OpenAI response"
	end
	if obj.error then
		local e = obj.error
		return nil, tostring(type(e) == "table" and (e.message or e.type) or e)
	end
	local choice = obj.choices and obj.choices[1]
	local msg = choice and choice.message
	local text = msg and msg.content
	if type(text) == "table" then
		local parts = {}
		for _, part in ipairs(text) do
			if type(part) == "table" and type(part.text) == "string" then
				table.insert(parts, part.text)
			elseif type(part) == "string" then
				table.insert(parts, part)
			end
		end
		text = table.concat(parts)
	end
	if type(text) ~= "string" or text == "" then
		return nil, "empty OpenAI response"
	end
	return text, nil
end

local function parseAnthropicMessagesResponse(body)
	local ok, obj = pcall(HttpService.JSONDecode, HttpService, body or "")
	if not ok or type(obj) ~= "table" then
		local text, sseErr = parseSSE(body or "")
		if text and text ~= "" then return text, nil end
		return nil, sseErr or "could not parse Anthropic response"
	end

	if obj.error then
		local e = obj.error
		if type(e) == "table" then
			return nil, tostring(e.message or e.type or "Anthropic error")
		end
		return nil, tostring(e)
	end

	local parts = {}
	if type(obj.content) == "table" then
		for _, block in ipairs(obj.content) do
			if type(block) == "table" and type(block.text) == "string" then
				table.insert(parts, block.text)
			elseif type(block) == "string" then
				table.insert(parts, block)
			end
		end
	end

	local text = table.concat(parts)
	if text == "" and type(obj.text) == "string" then text = obj.text end
	if text == "" then return nil, "empty Anthropic response" end
	return text, nil
end

local function buildProviderRequest(providerId, model, prompt)
	local p = PROVIDERS[providerId]
	if not p then return nil, nil, "unknown provider " .. tostring(providerId) end

	local apiKey = getProviderApiKey(providerId)
	if apiKey == nil then apiKey = "" end
	if apiKey == "" and not p.allow_empty_key then
		return nil, nil, "No API key set for " .. p.display
	end
	-- Some proxies accept empty/any key. Roblox headers are happier with a
	-- non-empty Authorization value, so use a harmless placeholder at request time.
	if apiKey == "" and p.allow_empty_key then
		apiKey = "dummy"
	end

	local baseUrl = getProviderBaseUrl(providerId)
	if not baseUrl or baseUrl == "" then
		return nil, nil, "No base URL set for " .. p.display
	end

	if p.api_type == "unlimited" then
		local payload = {
			message = prompt,
			model   = shortModelId(model.id),
			effort  = CONFIG.EFFORT,
		}
		local encoded = HttpService:JSONEncode(payload)
		return {
			Url = joinApiPath(baseUrl, "/api/chat"),
			Method = "POST",
			Headers = {
				["Content-Type"]  = "application/json",
				["Authorization"] = "Bearer " .. apiKey,
			},
			Body = encoded,
		}, function(respBody)
			local text, sseErr = parseSSE(respBody)
			if sseErr then return nil, "Stream error: " .. sseErr end
			if text == "" then return nil, "Empty response from API" end
			return text, nil
		end, nil, #encoded

	elseif p.api_type == "anthropic" then
		local base = trimTrailingSlash(baseUrl)
		local url = base:match("/v1$") and (base .. "/messages") or (base .. "/v1/messages")

		local payload = {
			model = model.id,
			max_tokens = 4096,
			messages = {
				{ role = "user", content = prompt },
			},
		}
		local encoded = HttpService:JSONEncode(payload)

		return {
			Url = url,
			Method = "POST",
			Headers = {
				["Content-Type"] = "application/json",
				["x-api-key"] = apiKey,
				["anthropic-version"] = "2023-06-01",
			},
			Body = encoded,
		}, parseAnthropicMessagesResponse, nil, #encoded

	elseif p.api_type == "openai" then
		local payload = {
			model = model.id,
			messages = {
				{ role = "user", content = prompt },
			},
			stream = false,
		}
		local encoded = HttpService:JSONEncode(payload)

		return {
			Url = joinApiPath(baseUrl, "/chat/completions"),
			Method = "POST",
			Headers = {
				["Content-Type"] = "application/json",
				["Authorization"] = "Bearer " .. apiKey,
			},
			Body = encoded,
		}, parseOpenAIChatResponse, nil, #encoded
	end

	return nil, nil, "unsupported provider type " .. tostring(p.api_type)
end

local function callAPI(history, includeTree, onProgress)
	local prompt = buildPrompt(history, includeTree)
	local model = getCurrentModel()

	if not model then
		return nil, "No model selected."
	end

	local providerId = model.provider
	local provider = PROVIDERS[providerId]
	if not provider then
		return nil, "Unknown provider for selected model: " .. tostring(providerId)
	end

	if onProgress then
		onProgress("🌐 " .. provider.display .. " · " .. model.display)
	end

	local request, parser, buildErr, encodedChars = buildProviderRequest(providerId, model, prompt)
	if buildErr then
		setProviderStatus(providerId, false)
		return nil, provider.display .. ": " .. buildErr
	end

	sessionStats.calls = sessionStats.calls + 1
	sessionStats.promptChars = sessionStats.promptChars + (encodedChars or #request.Body)

	if onProgress then
		onProgress("📊 ctx ~" .. math.floor((encodedChars or #request.Body) / 4) ..
			" tok (" .. (encodedChars or #request.Body) .. " chars)")
	end

	local function attempt()
		local done = false
		local response, requestErr

		task.spawn(function()
			local ok, resp = pcall(HttpService.RequestAsync, HttpService, request)
			if ok then response = resp else requestErr = tostring(resp) end
			done = true
		end)

		local timeout = CONFIG.HTTP_TIMEOUT_SEC or 90
		local elapsed = 0
		while not done do
			if isCancelled() then return nil, nil, false, true end
			elapsed = elapsed + task.wait(0.1)
			if elapsed > timeout then return nil, nil, true, false end
		end

		return response, requestErr, false, false
	end

	local MAX_ATTEMPTS = 2
	local backoff = 1
	local lastErr = provider.display .. " failed"

	for attemptNo = 1, MAX_ATTEMPTS do
		local response, requestErr, timedOut, cancelled = attempt()

		if cancelled then
			return nil, "Cancelled by user."
		elseif timedOut then
			lastErr = provider.display .. " timed out after " .. (CONFIG.HTTP_TIMEOUT_SEC or 90) .. "s"
		elseif requestErr then
			lastErr = provider.display .. " HTTP error: " .. requestErr
		elseif response.StatusCode == 200 then
			local text, parseErr = parser(response.Body)
			if text and text ~= "" then
				setProviderStatus(providerId, true)
				sessionStats.respChars = sessionStats.respChars + #text
				if onProgress then onProgress("✅ " .. provider.display .. " online") end
				return text, nil
			end
			lastErr = provider.display .. ": " .. tostring(parseErr or "empty response")
		elseif response.StatusCode == 429 or response.StatusCode >= 500 then
			lastErr = provider.display .. " API " .. response.StatusCode .. ": " ..
				(response.Body or ""):sub(1, 300)

			local ra = response.Headers
				and (response.Headers["retry-after"] or response.Headers["Retry-After"])
			local raNum = ra and tonumber(ra)
			if raNum and raNum > 0 then backoff = math.min(raNum, 30) end
		else
			lastErr = provider.display .. " API " .. response.StatusCode .. ": " ..
				(response.Body or ""):sub(1, 300)
			break
		end

		if attemptNo < MAX_ATTEMPTS then
			local waited = 0
			while waited < backoff do
				if isCancelled() then return nil, "Cancelled by user." end
				waited = waited + task.wait(0.1)
			end
			backoff = math.min(backoff * 2, 30)
		end
	end

	setProviderStatus(providerId, false)
	return nil, lastErr .. " (selected provider failed)"
end



-- ── Provider health check ────────────────────────────────────
-- Pings every configured provider with a tiny "hi" request and updates
-- providerStatus. A provider is considered online if:
--   • HTTP status is 200, OR
--   • the configured parser can extract non-empty text.
local PROVIDER_CHECK_TIMEOUT_SEC = 12

-- Last detailed health-check message per provider. The model picker still only
-- shows Online/Offline, but /providers reports these details.
local providerStatusDetail = {}

local function getHealthCheckModel(providerId)
	local current = getCurrentModel()
	if current and current.provider == providerId then
		return current
	end
	return getProviderDefaultModel(providerId)
end

local function classifyProviderFailure(statusCode, body, parseErr)
	statusCode = tonumber(statusCode) or 0
	body = tostring(body or "")
	parseErr = tostring(parseErr or "")

	local bodyPreview = body:gsub("%s+", " "):sub(1, 180)

	if statusCode == 0 then
		return parseErr ~= "" and parseErr or "no HTTP response"
	elseif statusCode == 400 then
		return "bad request / model may be invalid (HTTP 400)" .. (bodyPreview ~= "" and (": " .. bodyPreview) or "")
	elseif statusCode == 401 or statusCode == 403 then
		return "auth failed (HTTP " .. statusCode .. ")" .. (bodyPreview ~= "" and (": " .. bodyPreview) or "")
	elseif statusCode == 404 then
		return "endpoint or model not found (HTTP 404)" .. (bodyPreview ~= "" and (": " .. bodyPreview) or "")
	elseif statusCode == 408 then
		return "request timeout from provider (HTTP 408)"
	elseif statusCode == 429 then
		return "rate limited (HTTP 429)" .. (bodyPreview ~= "" and (": " .. bodyPreview) or "")
	elseif statusCode >= 500 then
		return "server error (HTTP " .. statusCode .. ")" .. (bodyPreview ~= "" and (": " .. bodyPreview) or "")
	elseif statusCode == 200 then
		if parseErr ~= "" then
			return "HTTP 200 but response was not valid assistant text: " .. parseErr
		end
		return "HTTP 200 but no assistant text"
	else
		return "HTTP " .. statusCode .. (bodyPreview ~= "" and (": " .. bodyPreview) or "")
	end
end

local function setProviderHealth(providerId, online, detail)
	setProviderStatus(providerId, online)
	providerStatusDetail[providerId] = tostring(detail or "")
	return online, providerStatusDetail[providerId]
end

local function checkOneProviderOnline(providerId)
	local provider = PROVIDERS[providerId]
	if not provider then
		return setProviderHealth(providerId, false, "unknown provider")
	end

	local model = getHealthCheckModel(providerId)
	if not model then
		return setProviderHealth(providerId, false, "no model configured")
	end

	local request, parser, buildErr = buildProviderRequest(providerId, model, "hi")
	if buildErr then
		return setProviderHealth(providerId, false, buildErr)
	end

	local done = false
	local response, requestErr

	task.spawn(function()
		local ok, resp = pcall(HttpService.RequestAsync, HttpService, request)
		if ok then
			response = resp
		else
			requestErr = tostring(resp)
		end
		done = true
	end)

	local elapsed = 0
	while not done do
		elapsed = elapsed + task.wait(0.1)
		if elapsed > PROVIDER_CHECK_TIMEOUT_SEC then
			return setProviderHealth(providerId, false, "timed out after " .. PROVIDER_CHECK_TIMEOUT_SEC .. "s")
		end
	end

	if requestErr then
		return setProviderHealth(providerId, false, "HTTP error: " .. requestErr)
	end

	if not response then
		return setProviderHealth(providerId, false, "no response")
	end

	local statusCode = tonumber(response.StatusCode) or 0
	local body = response.Body or ""

	-- Strict rule:
	--   Online = HTTP 200 AND parser extracts non-empty assistant text.
	-- This avoids false "online" for HTTP 200 error-shaped/malformed responses.
	if statusCode ~= 200 then
		return setProviderHealth(providerId, false, classifyProviderFailure(statusCode, body, nil))
	end

	if not parser then
		return setProviderHealth(providerId, false, "HTTP 200 but no response parser configured")
	end

	local okParse, text, parseErr = pcall(parser, body)
	if not okParse then
		return setProviderHealth(providerId, false, "HTTP 200 but parser crashed: " .. tostring(text))
	end

	if type(text) == "string" and text ~= "" then
		return setProviderHealth(providerId, true, "HTTP 200 · assistant text ok · model " .. tostring(model.id))
	end

	return setProviderHealth(providerId, false, classifyProviderFailure(statusCode, body, parseErr))
end

local function checkAllProvidersOnline(showBubbles)
	local results = {}
	local pending = 0

	-- Run checks in parallel so dead providers do not make the whole check take
	-- PROVIDER_CHECK_TIMEOUT_SEC × number_of_providers.
	for _, providerId in ipairs(PROVIDER_ORDER) do
		pending = pending + 1
		task.spawn(function()
			local online, detail = checkOneProviderOnline(providerId)
			results[providerId] = {
				online = online,
				detail = detail,
			}
			pending = pending - 1
		end)
	end

	local waited = 0
	local maxWait = PROVIDER_CHECK_TIMEOUT_SEC + 2
	while pending > 0 and waited < maxWait do
		waited = waited + task.wait(0.1)
	end

	local lines = { "🌐 Provider status check:" }

	for _, providerId in ipairs(PROVIDER_ORDER) do
		local provider = PROVIDERS[providerId]
		local r = results[providerId]

		if not r then
			setProviderHealth(providerId, false, "health check did not finish")
			r = {
				online = false,
				detail = "health check did not finish",
			}
		end

		table.insert(lines, string.format(
			"  %s %s — %s",
			r.online and "✅" or "❌",
			provider and provider.display or tostring(providerId),
			tostring(r.detail or "")
			))
	end

	local report = table.concat(lines, "\n")
	if showBubbles and addBubble then
		addBubble(report, "assistant")
	end
	return report
end


-- ── Attachment text extraction (POST /api/attachments/extract) ───────────
-- Reads a file the user picked in Studio, base64-encodes it, and asks the
-- server to extract readable text (PDF / DOCX / XLSX / text / code / data).
-- Images are NOT supported by the endpoint. 2 MB hard cap.
local ATTACH_MAX_BYTES = 2 * 1024 * 1024

local MIME_BY_EXT = {
	txt = "text/plain", md = "text/markdown", csv = "text/csv", json = "application/json",
	xml = "text/xml", html = "text/html", htm = "text/html", lua = "text/x-lua",
	luau = "text/x-lua", js = "text/javascript", ts = "text/plain", py = "text/x-python",
	log = "text/plain", yaml = "text/yaml", yml = "text/yaml", ini = "text/plain",
	toml = "text/plain", tsv = "text/tab-separated-values", rtf = "text/rtf",
	pdf = "application/pdf",
	docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
	xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}

local function guessMime(fileName)
	local ext = (fileName:match("%.([%w]+)$") or ""):lower()
	return MIME_BY_EXT[ext] or "application/octet-stream"
end

-- The documented response is a plain JSON object, but the API may also stream
-- SSE `data:` frames. Handle both and return the parsed result table.
local function parseExtractResponse(body)
	if not body or body == "" then return nil, "empty response" end
	local trimmed = body:match("^%s*(.-)%s*$")
	if trimmed:sub(1, 1) == "{" then
		local ok, obj = pcall(function() return HttpService:JSONDecode(trimmed) end)
		if ok and type(obj) == "table" then return obj, nil end
	end
	local textParts, result, sseErr = {}, nil, nil
	for line in (body .. "\n"):gmatch("(.-)\n") do
		local data = line:match("^data:%s?(.*)$")
		if data and data ~= "" and data ~= "[DONE]" then
			local ok, frame = pcall(function() return HttpService:JSONDecode(data) end)
			if ok and type(frame) == "table" then
				if frame.error then sseErr = tostring(frame.error) end
				if frame.delta then textParts[#textParts + 1] = tostring(frame.delta) end
				if frame.text then result = frame end
			end
		end
	end
	if sseErr then return nil, sseErr end
	if result then return result, nil end
	if #textParts > 0 then return { success = true, text = table.concat(textParts) }, nil end
	return nil, "could not parse response: " .. body:sub(1, 200)
end

-- Takes a File (from StudioService:PromptImportFile), extracts readable text,
-- and returns { name, text, parser, truncated, size } or nil + err.
--
-- Local-first behavior:
--   • Text/code/data files are read directly in Studio, with no API key needed.
--   • PDF/DOCX/XLSX still require Unlimited Surf AI's /api/attachments/extract
--     endpoint for now.
--   • Once text is extracted locally, it rides in the normal chat prompt, so it
--     works with the selected chat provider.
local LOCAL_TEXT_EXT = {
	txt = true, md = true, csv = true, tsv = true, json = true,
	xml = true, html = true, htm = true, lua = true, luau = true,
	js = true, ts = true, py = true, log = true, yaml = true,
	yml = true, ini = true, toml = true, rtf = true,
}

local LOCAL_ATTACHMENT_MAX_CHARS = 200000

local function getFileExt(fileName)
	return (tostring(fileName or ""):match("%.([%w]+)$") or ""):lower()
end

local function isLocalTextAttachment(fileName)
	return LOCAL_TEXT_EXT[getFileExt(fileName)] == true
end

local function sanitizeAttachmentText(bytes)
	bytes = tostring(bytes or "")

	-- UTF-8 BOM
	bytes = bytes:gsub("^\239\187\191", "")

	-- Null bytes are a strong sign of binary content and can make prompt/JSON
	-- handling ugly. Drop them for local text attachments.
	bytes = bytes:gsub("%z", "")

	-- If it is valid UTF-8, keep it as-is.
	if utf8 and utf8.len(bytes) then
		return bytes, false
	end

	-- Fallback for files that are mostly ASCII but contain invalid high bytes:
	-- keep tabs/newlines/printable ASCII and replace high bytes with '?' so
	-- HttpService:JSONEncode does not choke on invalid UTF-8.
	local out = {}
	local replaced = false
	for i = 1, #bytes do
		local b = bytes:byte(i)
		if b == 9 or b == 10 or b == 13 or (b >= 32 and b <= 126) then
			out[#out + 1] = string.char(b)
		elseif b >= 128 then
			out[#out + 1] = "?"
			replaced = true
		else
			-- skip other control characters
			replaced = true
		end
	end
	return table.concat(out), replaced
end

local function extractAttachment(file)
	local fileName = file.Name

	local okBin, bytes = pcall(function() return file:GetBinaryContents() end)
	if not okBin or type(bytes) ~= "string" then
		return nil, "could not read file contents: " .. tostring(bytes)
	end
	if #bytes == 0 then return nil, "file is empty." end
	if #bytes > ATTACH_MAX_BYTES then
		return nil, string.format("file is %.1f MB — exceeds the 2 MB limit.", #bytes / 1048576)
	end

	-- Local text/code/data path: no API key, no provider endpoint needed.
	if isLocalTextAttachment(fileName) then
		local text, repaired = sanitizeAttachmentText(bytes)
		if text == "" then
			return nil, "file had no readable text after sanitizing."
		end

		local truncated = false
		if #text > LOCAL_ATTACHMENT_MAX_CHARS then
			text = text:sub(1, LOCAL_ATTACHMENT_MAX_CHARS) ..
				"\n\n[... attachment truncated at " .. LOCAL_ATTACHMENT_MAX_CHARS .. " chars ...]"
			truncated = true
		end

		return {
			name = fileName,
			text = text,
			parser = repaired and "local-text-sanitized" or "local-text",
			truncated = truncated,
			size = #bytes,
		}, nil
	end

	-- Binary/document extraction path: currently Unlimited Surf AI only.
	local apiKey = getApiKey()
	if not apiKey or apiKey == "" then
		return nil,
		"this file type requires Unlimited Surf AI extraction. " ..
			"Add an Unlimited API key, or attach a text/code file instead."
	end

	local okB64, data = pcall(function() return HttpService:Base64Encode(bytes) end)
	if not okB64 then return nil, "base64 encode failed: " .. tostring(data) end

	local payload = HttpService:JSONEncode({
		name = fileName,
		type = guessMime(fileName),
		data = data,
	})

	local okReq, resp = pcall(HttpService.RequestAsync, HttpService, {
		Url    = CONFIG.BASE_URL .. "/api/attachments/extract",
		Method = "POST",
		Headers = {
			["Content-Type"]  = "application/json",
			["Authorization"] = "Bearer " .. apiKey,
		},
		Body = payload,
	})
	if not okReq then return nil, "HTTP error: " .. tostring(resp) end
	if resp.StatusCode ~= 200 then
		return nil, "API " .. resp.StatusCode .. ": " .. (resp.Body or ""):sub(1, 300)
	end

	local obj, perr = parseExtractResponse(resp.Body)
	if not obj then return nil, perr end
	if obj.success == false or obj.error then
		return nil, tostring(obj.error or "extraction failed")
	end
	if type(obj.text) ~= "string" then return nil, "response had no 'text' field" end

	return {
		name = obj.name or fileName,
		text = obj.text,
		parser = obj.parser or "unlimited-extract",
		truncated = obj.truncated == true,
		size = obj.size or #bytes,
	}, nil
end

-- ── Tool call extractor (tag-based) ──────────────────────────
local function extractToolCall(text)
	if not text then return nil end
	local tagged = text:match("<tool_call>%s*(.-)%s*</tool_call>")
	if tagged then return tagged end
	return nil
end

-- ── Main agentic loop ────────────────────────────────────────
-- Returns final assistant text, or nil + err.
-- `onProgress(msg)` is called whenever the status should update in the UI.
local function runConversationTurn(history, onProgress)
	for round = 1, CONFIG.MAX_TOOL_ROUNDS do
		if isCancelled() then
			return nil, "Cancelled by user."
		end

		-- Status: thinking
		if onProgress then
			onProgress("💭 Thinking… (round " .. round .. ")")
		end

		local assistantText, err = callAPI(history, round == 1, onProgress)
		if not assistantText then
			return nil, err
		end

		table.insert(history, { role = "assistant", content = assistantText })

		-- Was there a tool call?
		local callJson = extractToolCall(assistantText)
		if not callJson then
			-- No tool call → terminal turn, return assistant text
			return assistantText, nil
		end

		-- Parse the tool call JSON
		local parseOk, parsed = pcall(HttpService.JSONDecode, HttpService, callJson)
		if not parseOk or type(parsed) ~= "table" or not parsed.name then
			-- Include the raw payload so the model can see what got rejected
			local preview = tostring(callJson):sub(1, 500)
			local errMsg = parseOk and "missing 'name' field" or tostring(parsed)
			table.insert(history, {
				role = "user",
				content = "<tool_results>Tool call JSON parse failed: " .. errMsg ..
					"\n\nReceived payload (first 500 chars):\n" .. preview ..
					"\n\nFix: emit a raw JSON object inside <tool_call> tags only. " ..
					"If your payload contains many quotes/backslashes/newlines, " ..
					"use the b64 variants where supported.</tool_results>",
			})
			if onProgress then
				onProgress("⚠️ Malformed tool call — retrying (round " .. round .. ")")
			end
		else
			-- Status: running tool, with a hint of which target it's acting on
			if onProgress then
				local argHint = ""
				if type(parsed.args) == "table" then
					local hint = parsed.args.path
						or parsed.args.parent
						or parsed.args.class
						or parsed.args.name
						or (type(parsed.args.ops) == "table" and ("×" .. #parsed.args.ops))
						or parsed.args.store_name
					if hint then argHint = " · " .. tostring(hint) end
				end
				onProgress("🔧 " .. parsed.name .. argHint .. " (round " .. round .. ")")
			end

			-- Execute the tool
			local result = executeTool(parsed.name, parsed.args or {})

			-- Feed result back as a user turn so the model can continue
			table.insert(history, {
				role = "user",
				content = "<tool_results>\n" .. result .. "\n</tool_results>",
			})
		end
	end

	return nil, "Stopped after " .. CONFIG.MAX_TOOL_ROUNDS .. " tool rounds without a final answer."
end
-- ── Settings dialog ──────────────────────────────────────────
local settingsGui = nil
local function promptApiKey()
	-- Reuse the existing settings window if it's already open — Roblox does not
	-- allow creating a second PluginGui with the same id.
	if settingsGui then
		settingsGui.Enabled = true
		return
	end
	local info = DockWidgetPluginGuiInfo.new(
		Enum.InitialDockState.Float, true, true, 460, 620, 460, 620
	)
	local gui = plugin:CreateDockWidgetPluginGui("UnlimitedAI_Settings", info)
	settingsGui = gui
	gui.Title = "Unlimited AI — Settings"
	-- If the user closes the window with the X, drop our cached reference and
	-- destroy it so a fresh one can be built next time.
	gui:GetPropertyChangedSignal("Enabled"):Connect(function()
		if not gui.Enabled then
			settingsGui = nil
			gui:Destroy()
		end
	end)

	-- ── Outer frame ─────────────────────────────────────────
	local outer = Instance.new("Frame")
	outer.Size = UDim2.new(1, 0, 1, 0)
	outer.BackgroundColor3 = Color3.fromRGB(28, 28, 36)
	outer.BorderSizePixel = 0
	outer.Parent = gui

	-- Scroll area so the dialog works on smaller screens
	local scrollArea = Instance.new("ScrollingFrame")
	scrollArea.Size = UDim2.new(1, 0, 1, -56)
	scrollArea.Position = UDim2.new(0, 0, 0, 0)
	scrollArea.BackgroundTransparency = 1
	scrollArea.BorderSizePixel = 0
	scrollArea.ScrollBarThickness = 4
	scrollArea.AutomaticCanvasSize = Enum.AutomaticSize.Y
	scrollArea.CanvasSize = UDim2.new(0, 0, 0, 0)
	scrollArea.Parent = outer

	local layout = Instance.new("UIListLayout")
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Padding = UDim.new(0, 4)
	layout.Parent = scrollArea

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, 12); pad.PaddingBottom = UDim.new(0, 12)
	pad.PaddingLeft = UDim.new(0, 14); pad.PaddingRight = UDim.new(0, 14)
	pad.Parent = scrollArea

	-- Local working copy — only flushed to disk on Save
	local working = {
		api_key              = getApiKey() or "",
		base_url             = getSetting("base_url"),
		homelander_base_url  = getSetting("homelander_base_url"),
		homelander_api_key   = getSetting("homelander_api_key"),
		gpt_base_url         = getSetting("gpt_base_url"),
		gpt_api_key          = getSetting("gpt_api_key"),
		deepcode_base_url    = getSetting("deepcode_base_url"),
		deepcode_api_key     = getSetting("deepcode_api_key"),
		http_timeout_sec     = getSetting("http_timeout_sec"),
		default_model_id     = plugin:GetSetting("unlimited_model_id") or getProviderDefaultModel("deepcode").id,
		default_provider_id  = plugin:GetSetting("unlimited_model_provider") or "deepcode",
		effort           = getSetting("effort"),
		max_tool_rounds  = getSetting("max_tool_rounds"),
		max_prompt_chars = getSetting("max_prompt_chars"),
		max_script_chars = getSetting("max_script_chars"),
		max_tree_chars   = getSetting("max_tree_chars"),
		debug_prints     = getSetting("debug_prints"),
		bypass_all       = getSetting("bypass_all"),
		disable_helper_objects = getSetting("disable_helper_objects"),
	}

	local sectionOrder = 0

	-- ── Section header helper ───────────────────────────────
	local function addSection(title)
		sectionOrder = sectionOrder + 1
		local label = Instance.new("TextLabel")
		label.Size = UDim2.new(1, 0, 0, 22)
		label.BackgroundTransparency = 1
		label.Text = "── " .. title .. " " .. string.rep("─", 32)
		label.TextColor3 = Color3.fromRGB(140, 160, 200)
		label.Font = Enum.Font.GothamMedium
		label.TextSize = 12
		label.TextXAlignment = Enum.TextXAlignment.Left
		label.LayoutOrder = sectionOrder
		label.Parent = scrollArea
		return label
	end

	-- ── Field row helpers ───────────────────────────────────
	local function addRow(labelText)
		sectionOrder = sectionOrder + 1
		local row = Instance.new("Frame")
		row.Size = UDim2.new(1, 0, 0, 32)
		row.BackgroundTransparency = 1
		row.LayoutOrder = sectionOrder
		row.Parent = scrollArea

		local label = Instance.new("TextLabel")
		label.Size = UDim2.new(0.42, 0, 1, 0)
		label.BackgroundTransparency = 1
		label.Text = labelText
		label.TextColor3 = Color3.fromRGB(210, 215, 230)
		label.Font = Enum.Font.Gotham
		label.TextSize = 12
		label.TextXAlignment = Enum.TextXAlignment.Left
		label.Parent = row

		return row
	end

	local function addTextField(labelText, currentValue, placeholder, monospace, isPassword)
		local row = addRow(labelText)
		local box = Instance.new("TextBox")
		box.Size = UDim2.new(0.58, 0, 1, -4)
		box.Position = UDim2.new(0.42, 0, 0, 2)
		box.BackgroundColor3 = Color3.fromRGB(40, 40, 56)
		box.BorderSizePixel = 0
		box.Text = tostring(currentValue or "")
		box.PlaceholderText = placeholder or ""
		box.PlaceholderColor3 = Color3.fromRGB(100, 100, 120)
		box.TextColor3 = Color3.fromRGB(220, 220, 240)
		box.TextSize = 12
		box.Font = monospace and Enum.Font.RobotoMono or Enum.Font.Gotham
		box.ClearTextOnFocus = false
		box.TextXAlignment = Enum.TextXAlignment.Left
		box.Parent = row
		Instance.new("UICorner", box).CornerRadius = UDim.new(0, 4)

		local boxPad = Instance.new("UIPadding")
		boxPad.PaddingLeft = UDim.new(0, 6); boxPad.PaddingRight = UDim.new(0, 6)
		boxPad.Parent = box

		return box
	end

	local function addSegmentedChoice(labelText, currentValue, options)
		local row = addRow(labelText)
		local container = Instance.new("Frame")
		container.Size = UDim2.new(0.58, 0, 1, -4)
		container.Position = UDim2.new(0.42, 0, 0, 2)
		container.BackgroundTransparency = 1
		container.Parent = row

		local cl = Instance.new("UIListLayout")
		cl.FillDirection = Enum.FillDirection.Horizontal
		cl.Padding = UDim.new(0, 4)
		cl.SortOrder = Enum.SortOrder.LayoutOrder
		cl.Parent = container

		local selected = currentValue
		local buttons = {}
		local function refresh()
			for value, btn in pairs(buttons) do
				if value == selected then
					btn.BackgroundColor3 = Color3.fromRGB(60, 110, 220)
					btn.TextColor3 = Color3.fromRGB(255, 255, 255)
				else
					btn.BackgroundColor3 = Color3.fromRGB(40, 40, 56)
					btn.TextColor3 = Color3.fromRGB(180, 185, 210)
				end
			end
		end
		for i, opt in ipairs(options) do
			local btn = Instance.new("TextButton")
			btn.Size = UDim2.new(1 / #options, -4, 1, 0)
			btn.BackgroundColor3 = Color3.fromRGB(40, 40, 56)
			btn.BorderSizePixel = 0
			btn.Text = opt.label
			btn.TextSize = 12
			btn.Font = Enum.Font.GothamMedium
			btn.AutoButtonColor = false
			btn.LayoutOrder = i
			btn.Parent = container
			Instance.new("UICorner", btn).CornerRadius = UDim.new(0, 4)
			buttons[opt.value] = btn
			btn.MouseButton1Click:Connect(function()
				selected = opt.value
				refresh()
			end)
		end
		refresh()

		return { get = function() return selected end }
	end

	local function addToggle(labelText, currentValue)
		local row = addRow(labelText)
		local state = currentValue and true or false
		local btn = Instance.new("TextButton")
		btn.Size = UDim2.new(0, 60, 1, -8)
		btn.Position = UDim2.new(0.42, 0, 0, 4)
		btn.BackgroundColor3 = state and Color3.fromRGB(60, 130, 80) or Color3.fromRGB(60, 60, 78)
		btn.BorderSizePixel = 0
		btn.Text = state and "ON" or "OFF"
		btn.TextColor3 = Color3.fromRGB(255, 255, 255)
		btn.Font = Enum.Font.GothamMedium
		btn.TextSize = 11
		btn.AutoButtonColor = false
		btn.Parent = row
		Instance.new("UICorner", btn).CornerRadius = UDim.new(0, 4)
		btn.MouseButton1Click:Connect(function()
			state = not state
			btn.Text = state and "ON" or "OFF"
			btn.BackgroundColor3 = state and Color3.fromRGB(60, 130, 80) or Color3.fromRGB(60, 60, 78)
		end)
		return { get = function() return state end }
	end

	local function addModelDropdown(labelText, currentModelId, currentProviderId)
		local row = addRow(labelText)
		local current = currentModelId
		local currentProvider = currentProviderId or "unlimited"

		local btn = Instance.new("TextButton")
		btn.Size = UDim2.new(0.58, 0, 1, -4)
		btn.Position = UDim2.new(0.42, 0, 0, 2)
		btn.BackgroundColor3 = Color3.fromRGB(40, 40, 56)
		btn.BorderSizePixel = 0
		btn.TextColor3 = Color3.fromRGB(220, 220, 240)
		btn.TextSize = 12
		btn.Font = Enum.Font.Gotham
		btn.TextXAlignment = Enum.TextXAlignment.Left
		btn.AutoButtonColor = false
		btn.Parent = row
		Instance.new("UICorner", btn).CornerRadius = UDim.new(0, 4)

		local btnPad = Instance.new("UIPadding")
		btnPad.PaddingLeft = UDim.new(0, 8); btnPad.PaddingRight = UDim.new(0, 8)
		btnPad.Parent = btn

		local function refresh()
			for _, m in ipairs(MODELS) do
				if m.id == current and m.provider == currentProvider then
					btn.Text = m.providerDisplay .. " · " .. m.display .. "  ▾"
					return
				end
			end
			btn.Text = "(select model)  ▾"
		end
		refresh()

		local menu
		local function closeMenu()
			if menu then menu:Destroy(); menu = nil end
		end

		btn.MouseButton1Click:Connect(function()
			if menu then closeMenu(); return end

			menu = Instance.new("Frame")
			menu.Size = UDim2.new(0, 270, 0, 320)
			menu.Position = UDim2.new(0, btn.AbsolutePosition.X - row.AbsolutePosition.X, 0, 32)
			menu.BackgroundColor3 = Color3.fromRGB(24, 24, 34)
			menu.BorderSizePixel = 0
			menu.ZIndex = 20
			menu.Parent = row
			Instance.new("UICorner", menu).CornerRadius = UDim.new(0, 6)

			local stroke = Instance.new("UIStroke")
			stroke.Color = Color3.fromRGB(60, 60, 80)
			stroke.Thickness = 1
			stroke.Parent = menu

			local list = Instance.new("ScrollingFrame")
			list.Size = UDim2.new(1, -4, 1, -4)
			list.Position = UDim2.new(0, 2, 0, 2)
			list.BackgroundTransparency = 1
			list.BorderSizePixel = 0
			list.ScrollBarThickness = 3
			list.AutomaticCanvasSize = Enum.AutomaticSize.Y
			list.CanvasSize = UDim2.new(0, 0, 0, 0)
			list.ZIndex = 21
			list.Parent = menu

			local sl = Instance.new("UIListLayout")
			sl.Padding = UDim.new(0, 2)
			sl.SortOrder = Enum.SortOrder.LayoutOrder
			sl.Parent = list

			local order = 0
			for _, providerId in ipairs(PROVIDER_ORDER) do
				local p = PROVIDERS[providerId]
				order = order + 1

				local header = Instance.new("Frame")
				header.Size = UDim2.new(1, -4, 0, 42)
				header.BackgroundColor3 = Color3.fromRGB(30, 30, 44)
				header.BorderSizePixel = 0
				header.LayoutOrder = order
				header.ZIndex = 22
				header.Parent = list
				Instance.new("UICorner", header).CornerRadius = UDim.new(0, 4)

				local h1 = Instance.new("TextLabel")
				h1.Size = UDim2.new(1, -12, 0, 21)
				h1.Position = UDim2.new(0, 8, 0, 3)
				h1.BackgroundTransparency = 1
				h1.Text = p.display
				h1.TextColor3 = Color3.fromRGB(230, 235, 255)
				h1.TextSize = 12
				h1.Font = Enum.Font.GothamBold
				h1.TextXAlignment = Enum.TextXAlignment.Left
				h1.ZIndex = 23
				h1.Parent = header

				local h2 = Instance.new("TextLabel")
				h2.Size = UDim2.new(1, -12, 0, 16)
				h2.Position = UDim2.new(0, 8, 0, 22)
				h2.BackgroundTransparency = 1
				h2.Text = "● " .. getProviderStatusText(providerId)
				h2.TextColor3 = getProviderStatusColor(providerId)
				h2.TextSize = 10
				h2.Font = Enum.Font.Gotham
				h2.TextXAlignment = Enum.TextXAlignment.Left
				h2.ZIndex = 23
				h2.Parent = header

				for _, modelDef in ipairs(p.models) do
					order = order + 1
					local selected = (modelDef.id == current and providerId == currentProvider)

					local item = Instance.new("TextButton")
					item.Size = UDim2.new(1, -4, 0, 24)
					item.BackgroundColor3 = selected and Color3.fromRGB(48, 60, 100) or Color3.fromRGB(36, 36, 50)
					item.BorderSizePixel = 0
					item.Text = (selected and "  ✓ " or "    ") .. modelDef.display
					item.TextColor3 = Color3.fromRGB(220, 220, 240)
					item.TextSize = 12
					item.Font = Enum.Font.Gotham
					item.TextXAlignment = Enum.TextXAlignment.Left
					item.AutoButtonColor = false
					item.LayoutOrder = order
					item.ZIndex = 22
					item.Parent = list
					Instance.new("UICorner", item).CornerRadius = UDim.new(0, 4)

					item.MouseButton1Click:Connect(function()
						current = modelDef.id
						currentProvider = providerId
						refresh()
						closeMenu()
					end)
				end
			end
		end)

		return {
			get = function() return current end,
			getProvider = function() return currentProvider end,
		}
	end


	-- ── Build the sections ──────────────────────────────────
	addSection("Connection")
	local apiKeyBox = addTextField("Unlimited API key", working.api_key, "ua_...", true, true)
	local baseUrlBox = addTextField("Unlimited Base URL", working.base_url, "https://unlimited.surf", true)
	local homelanderBaseBox = addTextField("Homelander Base URL", working.homelander_base_url, "https://your-server", true)
	local homelanderKeyBox = addTextField("Homelander API key", working.homelander_api_key, "homelander", true, true)
	local gptBaseBox = addTextField("GPT 5.5 Base URL", working.gpt_base_url, "https://theproxy-production-e112.up.railway.app/v1", true)
	local gptKeyBox = addTextField("GPT 5.5 API key", working.gpt_api_key, "admin", true, true)
	local deepcodeBaseBox = addTextField("DeepCode Base URL", working.deepcode_base_url, "https://use-ai-production.up.railway.app/v1", true)
	local deepcodeKeyBox = addTextField("DeepCode API key", working.deepcode_api_key, "(empty works)", true, true)
	local timeoutBox = addTextField("HTTP timeout (s)", working.http_timeout_sec, "90")

	addSection("Model")
	local modelCtl  = addModelDropdown("Default model", working.default_model_id, working.default_provider_id)
	local effortCtl = addSegmentedChoice("Effort", working.effort, {
		{ label = "Low",    value = "low" },
		{ label = "Medium", value = "medium" },
		{ label = "High",   value = "high" },
	})
	local roundsBox = addTextField("Max tool rounds", working.max_tool_rounds, "12")

	addSection("Budgets")
	local promptBox = addTextField("Prompt cap (chars)", working.max_prompt_chars, "600000")
	local scriptBox = addTextField("Script read cap",    working.max_script_chars, "200000")
	local treeBox   = addTextField("Tree scan cap",      working.max_tree_chars,   "300000")

	addSection("Trust")
	sectionOrder = sectionOrder + 1
	local trustRow = Instance.new("Frame")
	trustRow.Size = UDim2.new(1, 0, 0, 32)
	trustRow.BackgroundTransparency = 1
	trustRow.LayoutOrder = sectionOrder
	trustRow.Parent = scrollArea
	local trustLayout = Instance.new("UIListLayout")
	trustLayout.FillDirection = Enum.FillDirection.Horizontal
	trustLayout.Padding = UDim.new(0, 8)
	trustLayout.Parent = trustRow

	local function makeActionBtn(label, color, callback)
		local b = Instance.new("TextButton")
		b.Size = UDim2.new(0, 180, 1, -4)
		b.BackgroundColor3 = color
		b.BorderSizePixel = 0
		b.Text = label
		b.TextColor3 = Color3.fromRGB(255, 255, 255)
		b.Font = Enum.Font.GothamMedium
		b.TextSize = 11
		b.Parent = trustRow
		Instance.new("UICorner", b).CornerRadius = UDim.new(0, 4)
		b.MouseButton1Click:Connect(callback)
		return b
	end

	makeActionBtn("Revoke session approvals", Color3.fromRGB(120, 70, 70), function()
		sessionAllow = {}
		addBubble("🔒 All session approvals revoked.", "tool")
	end)
	makeActionBtn("Clear conversation", Color3.fromRGB(80, 80, 110), function()
		messageHistory = {}
		addBubble("🧹 Conversation cleared.", "tool")
	end)

	addSection("Permissions")
	local bypassCtl = addToggle("Bypass all confirmations", working.bypass_all)
	-- Warning caption under the bypass toggle
	sectionOrder = sectionOrder + 1
	local bypassWarn = Instance.new("TextLabel")
	bypassWarn.Size = UDim2.new(1, 0, 0, 30)
	bypassWarn.BackgroundTransparency = 1
	bypassWarn.Text = "⚠ Skips the confirm prompt for destructive / code-running tools (delete, set source, run_script, …). Stays ON across plugin reloads until you turn it off here."
	bypassWarn.TextColor3 = Color3.fromRGB(220, 150, 150)
	bypassWarn.Font = Enum.Font.Gotham
	bypassWarn.TextSize = 11
	bypassWarn.TextWrapped = true
	bypassWarn.TextXAlignment = Enum.TextXAlignment.Left
	bypassWarn.TextYAlignment = Enum.TextYAlignment.Top
	bypassWarn.LayoutOrder = sectionOrder
	bypassWarn.Parent = scrollArea

	addSection("Diagnostics")
	local debugCtl = addToggle("Debug prints in Output", working.debug_prints)

	addSection("Plugin helper objects")
	local helperCtl = addToggle("Stop creating plugin helper objects", working.disable_helper_objects)
	sectionOrder = sectionOrder + 1
	local helperWarn = Instance.new("TextLabel")
	helperWarn.Size = UDim2.new(1, 0, 0, 44)
	helperWarn.BackgroundTransparency = 1
	helperWarn.Text = "⚠ When ON, the plugin won't create its helper objects on load " ..
		"(the ServerScriptService." .. CONFIG.PLANS_ROOT_NAME .. " Plans/SystemPrompt folders " ..
		"and the Workspace test rig). Plan storage, read_system_prompt, and the default " ..
		"rig stop working until you turn this back off. Takes effect on the next plugin reload."
	helperWarn.TextColor3 = Color3.fromRGB(220, 190, 150)
	helperWarn.Font = Enum.Font.Gotham
	helperWarn.TextSize = 11
	helperWarn.TextWrapped = true
	helperWarn.TextXAlignment = Enum.TextXAlignment.Left
	helperWarn.TextYAlignment = Enum.TextYAlignment.Top
	helperWarn.LayoutOrder = sectionOrder
	helperWarn.Parent = scrollArea

	sectionOrder = sectionOrder + 1
	local deleteBtn = Instance.new("TextButton")
	deleteBtn.Size = UDim2.new(1, 0, 0, 30)
	deleteBtn.BackgroundColor3 = Color3.fromRGB(120, 60, 60)
	deleteBtn.BorderSizePixel = 0
	deleteBtn.Text = "🗑 Delete all plugin-made objects"
	deleteBtn.TextColor3 = Color3.fromRGB(255, 235, 235)
	deleteBtn.Font = Enum.Font.GothamMedium
	deleteBtn.TextSize = 12
	deleteBtn.LayoutOrder = sectionOrder
	deleteBtn.Parent = scrollArea
	Instance.new("UICorner", deleteBtn).CornerRadius = UDim.new(0, 4)
	deleteBtn.MouseButton1Click:Connect(function()
		-- Remove the helper objects this plugin creates for development:
		-- the ServerScriptService.<root> folder (Plans + SystemPrompt) and the
		-- Workspace.<root> test-rig folder. Wrapped in one undo waypoint so a
		-- single Ctrl+Z brings them back.
		local removed = {}
		local function tryRemove(parent, childName)
			local obj = parent:FindFirstChild(childName)
			if obj then
				local full = obj:GetFullName()
				local ok = pcall(function() obj:Destroy() end)
				if ok then table.insert(removed, full) end
			end
		end
		local recorded = pcall(function()
			ChangeHistoryService:SetWaypoint("Before Unlimited AI cleanup")
		end)
		tryRemove(ServerScriptService, CONFIG.PLANS_ROOT_NAME)
		tryRemove(game:GetService("Workspace"), CONFIG.RIG_FOLDER_NAME)
		if recorded then
			pcall(function() ChangeHistoryService:SetWaypoint("Unlimited AI cleanup") end)
		end
		if #removed == 0 then
			addBubble("🧹 No plugin-made objects found to delete (already clean).", "tool")
		else
			addBubble("🗑 Deleted plugin-made objects (Ctrl+Z to undo):\n• " ..
				table.concat(removed, "\n• "), "tool")
		end
	end)

	-- ── Bottom action bar (always visible, outside scroll) ──
	local actionBar = Instance.new("Frame")
	actionBar.Size = UDim2.new(1, 0, 0, 56)
	actionBar.Position = UDim2.new(0, 0, 1, -56)
	actionBar.BackgroundColor3 = Color3.fromRGB(22, 22, 30)
	actionBar.BorderSizePixel = 0
	actionBar.Parent = outer

	local barLayout = Instance.new("UIListLayout")
	barLayout.FillDirection = Enum.FillDirection.Horizontal
	barLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
	barLayout.VerticalAlignment = Enum.VerticalAlignment.Center
	barLayout.Padding = UDim.new(0, 8)
	barLayout.Parent = actionBar
	local barPad = Instance.new("UIPadding")
	barPad.PaddingLeft = UDim.new(0, 14); barPad.PaddingRight = UDim.new(0, 14)
	barPad.Parent = actionBar

	local resetBtn = Instance.new("TextButton")
	resetBtn.Size = UDim2.new(0, 130, 0, 32)
	resetBtn.BackgroundColor3 = Color3.fromRGB(60, 60, 78)
	resetBtn.BorderSizePixel = 0
	resetBtn.Text = "Reset to defaults"
	resetBtn.TextColor3 = Color3.fromRGB(220, 220, 240)
	resetBtn.Font = Enum.Font.GothamMedium
	resetBtn.TextSize = 12
	resetBtn.LayoutOrder = 1
	resetBtn.Parent = actionBar
	Instance.new("UICorner", resetBtn).CornerRadius = UDim.new(0, 6)

	local saveBtn = Instance.new("TextButton")
	saveBtn.Size = UDim2.new(0, 100, 0, 32)
	saveBtn.BackgroundColor3 = Color3.fromRGB(60, 110, 220)
	saveBtn.BorderSizePixel = 0
	saveBtn.Text = "Save"
	saveBtn.TextColor3 = Color3.fromRGB(255, 255, 255)
	saveBtn.Font = Enum.Font.GothamMedium
	saveBtn.TextSize = 13
	saveBtn.LayoutOrder = 2
	saveBtn.Parent = actionBar
	Instance.new("UICorner", saveBtn).CornerRadius = UDim.new(0, 6)

	local reloadBtn = Instance.new("TextButton")
	reloadBtn.Size = UDim2.new(0, 120, 0, 32)
	reloadBtn.BackgroundColor3 = Color3.fromRGB(70, 90, 60)
	reloadBtn.BorderSizePixel = 0
	reloadBtn.Text = "🔄 Reload plugin"
	reloadBtn.TextColor3 = Color3.fromRGB(220, 240, 210)
	reloadBtn.Font = Enum.Font.GothamMedium
	reloadBtn.TextSize = 12
	reloadBtn.LayoutOrder = 0
	reloadBtn.Parent = actionBar
	Instance.new("UICorner", reloadBtn).CornerRadius = UDim.new(0, 6)

	reloadBtn.MouseButton1Click:Connect(function()
		-- Soft reload: re-read saved settings into CONFIG and re-scan the tree.
		-- Roblox has no API to fully restart a plugin, so unsaved fields above are
		-- ignored — click Save first if you changed values you want applied.
		cachedSettings = nil
		applySettingsToConfig()
		setBypass(getSetting("bypass_all"))
		if refreshTitle then pcall(refreshTitle) end
		if refreshBypassBanner then pcall(refreshBypassBanner) end
		gui.Enabled = false
		gui:Destroy()
		addBubble("🔄 Plugin reloaded — saved settings re-applied. Re-scanning…", "tool")
		if doScan then task.spawn(doScan) end
	end)

	-- ── Save / reset handlers ───────────────────────────────
	local function parsePositiveInt(text, fallback)
		local n = tonumber(text)
		if not n or n < 1 then return fallback end
		return math.floor(n)
	end

	saveBtn.MouseButton1Click:Connect(function()
		-- API key
		setApiKey(apiKeyBox.Text:match("^%s*(.-)%s*$"))

		-- Model
		setCurrentModel(modelCtl.get(), modelCtl.getProvider())
		if refreshTitle then pcall(refreshTitle) end

		-- Everything else into the settings blob
		saveSettings({
			base_url             = baseUrlBox.Text:match("^%s*(.-)%s*$"),
			homelander_base_url = homelanderBaseBox.Text:match("^%s*(.-)%s*$"),
			homelander_api_key  = homelanderKeyBox.Text:match("^%s*(.-)%s*$"),
			gpt_base_url        = gptBaseBox.Text:match("^%s*(.-)%s*$"),
			gpt_api_key         = gptKeyBox.Text:match("^%s*(.-)%s*$"),
			deepcode_base_url   = deepcodeBaseBox.Text:match("^%s*(.-)%s*$"),
			deepcode_api_key    = deepcodeKeyBox.Text:match("^%s*(.-)%s*$"),
			http_timeout_sec    = parsePositiveInt(timeoutBox.Text, 90),
			effort           = effortCtl.get(),
			max_tool_rounds  = parsePositiveInt(roundsBox.Text, 12),
			max_prompt_chars = parsePositiveInt(promptBox.Text, 600000),
			max_script_chars = parsePositiveInt(scriptBox.Text, 200000),
			max_tree_chars   = parsePositiveInt(treeBox.Text, 300000),
			debug_prints     = debugCtl.get(),
			bypass_all       = bypassCtl.get(),
			disable_helper_objects = helperCtl.get(),
		})

		-- Push the freshly-saved values into CONFIG so they take effect right away
		-- (timeout, effort, budgets) — no plugin reload required.
		applySettingsToConfig()
		setBypass(bypassCtl.get())
		if refreshBypassBanner then pcall(refreshBypassBanner) end
		task.spawn(function()
			checkAllProvidersOnline(false)
		end)

		addBubble("⚙ Settings saved and applied — timeout, effort and budgets are live now. Provider status check started.", "tool")
		gui.Enabled = false
		gui:Destroy()
	end)

	resetBtn.MouseButton1Click:Connect(function()
		resetSettings()
		addBubble("⚙ Settings reset to defaults. Reload the plugin for changes to take effect.", "tool")
		gui.Enabled = false
		gui:Destroy()
	end)
end
-- ── Toolbar + main widget ────────────────────────────────────
local toolbar     = plugin:CreateToolbar("Unlimited AI")
local toggleBtn   = toolbar:CreateButton("AI Chat", "Open Unlimited AI Chat", "rbxassetid://79434891959545")
local settingsBtn = toolbar:CreateButton("Settings", "Set API key", "rbxassetid://121715259617240")
local snapshotBtn = toolbar:CreateButton("Snapshot", "Save all script sources (revert later with revert_script)", "rbxassetid://140133810400695")
local attachBtn   = toolbar:CreateButton("Attach File", "Extract text from a file (PDF/DOCX/XLSX/text) and attach it to chat", "rbxassetid://6022668885")

local widgetInfo = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Right, true, false, 420, 640, 320, 400
)
local widget = plugin:CreateDockWidgetPluginGui("UnlimitedAI", widgetInfo)
widget.Title = "Unlimited AI"

local bg = Instance.new("Frame")
bg.Size = UDim2.new(1, 0, 1, 0)
bg.BackgroundColor3 = Color3.fromRGB(28, 28, 36)
bg.BorderSizePixel = 0
bg.Parent = widget

-- Title bar
local titleBar = Instance.new("Frame")
titleBar.Size = UDim2.new(1, 0, 0, 36)
titleBar.BackgroundColor3 = Color3.fromRGB(18, 18, 26)
titleBar.BorderSizePixel = 0
titleBar.Parent = bg

-- Title button → now a clickable model picker button
local titleBtn = Instance.new("TextButton")
titleBtn.Size = UDim2.new(1, -10, 1, 0)
titleBtn.Position = UDim2.new(0, 10, 0, 0)
titleBtn.BackgroundTransparency = 1
titleBtn.TextColor3 = Color3.fromRGB(190, 200, 230)
titleBtn.TextSize = 13
titleBtn.Font = Enum.Font.GothamMedium
titleBtn.TextXAlignment = Enum.TextXAlignment.Left
titleBtn.AutoButtonColor = false
titleBtn.Parent = titleBar

refreshTitle = function()
	local m = getCurrentModel()
	titleBtn.Text = "🤖 " .. m.providerDisplay .. " · " .. m.display .. "  ▾"
end
refreshTitle()

-- Hover feedback
titleBtn.MouseEnter:Connect(function()
	titleBtn.TextColor3 = Color3.fromRGB(230, 240, 255)
end)
titleBtn.MouseLeave:Connect(function()
	titleBtn.TextColor3 = Color3.fromRGB(190, 200, 230)
end)-- ── Bypass banner (visible only while bypass is active) ─────
local bypassBanner = Instance.new("Frame")
bypassBanner.Size = UDim2.new(1, 0, 0, 22)
bypassBanner.Position = UDim2.new(0, 0, 0, 36)  -- sits below the title bar
bypassBanner.BackgroundColor3 = Color3.fromRGB(180, 40, 40)
bypassBanner.BorderSizePixel = 0
bypassBanner.Visible = false
bypassBanner.ZIndex = 5
bypassBanner.Parent = bg

local bannerLabel = Instance.new("TextLabel")
bannerLabel.Size = UDim2.new(1, -20, 1, 0)
bannerLabel.Position = UDim2.new(0, 10, 0, 0)
bannerLabel.BackgroundTransparency = 1
bannerLabel.Text = "⚡  BYPASS MODE ACTIVE — high-risk tools run without confirmation"
bannerLabel.TextColor3 = Color3.fromRGB(255, 230, 230)
bannerLabel.Font = Enum.Font.GothamBold
bannerLabel.TextSize = 11
bannerLabel.TextXAlignment = Enum.TextXAlignment.Left
bannerLabel.ZIndex = 6
bannerLabel.Parent = bypassBanner

refreshBypassBanner = function()
	if not bypassBanner or not bypassBanner.Parent then return end
	local active = isBypassActive()
	bypassBanner.Visible = active
	-- Slide everything below the banner down/up to make room
	local offset = active and 22 or 0
	if statusBar then
		statusBar.Position = UDim2.new(0, 0, 0, 36 + offset)
	end
	if scroll then
		scroll.Position = UDim2.new(0, 6, 0, 66 + offset)
		scroll.Size = UDim2.new(1, -12, 1, -136 - offset)
	end
end
-- Status bar
local statusBar = Instance.new("Frame")
statusBar.Size = UDim2.new(1, 0, 0, 26)
statusBar.Position = UDim2.new(0, 0, 0, 36)
statusBar.BackgroundColor3 = Color3.fromRGB(35, 50, 35)
statusBar.BorderSizePixel = 0
statusBar.Parent = bg

local statusLabel = Instance.new("TextLabel")
statusLabel.Size = UDim2.new(1, -180, 1, 0)
statusLabel.Position = UDim2.new(0, 8, 0, 0)
statusLabel.BackgroundTransparency = 1
statusLabel.Text = "⏳ Game not scanned yet"
statusLabel.TextColor3 = Color3.fromRGB(160, 210, 160)
statusLabel.TextSize = 11
statusLabel.Font = Enum.Font.Gotham
statusLabel.TextXAlignment = Enum.TextXAlignment.Left
statusLabel.TextTruncate = Enum.TextTruncate.AtEnd
statusLabel.Parent = statusBar

local rescanBtn = Instance.new("TextButton")
rescanBtn.Size = UDim2.new(0, 90, 1, -6)
rescanBtn.Position = UDim2.new(1, -95, 0, 3)
rescanBtn.BackgroundColor3 = Color3.fromRGB(60, 100, 60)
rescanBtn.BorderSizePixel = 0
rescanBtn.Text = "🔄 Re-scan"
rescanBtn.TextColor3 = Color3.fromRGB(200, 240, 200)
rescanBtn.TextSize = 11
rescanBtn.Font = Enum.Font.GothamMedium
rescanBtn.Parent = statusBar
Instance.new("UICorner", rescanBtn).CornerRadius = UDim.new(0, 4)

-- Clear-history button (sits just left of Re-scan)
local clearChatBtn = Instance.new("TextButton")
clearChatBtn.Size = UDim2.new(0, 72, 1, -6)
clearChatBtn.Position = UDim2.new(1, -172, 0, 3)
clearChatBtn.BackgroundColor3 = Color3.fromRGB(90, 70, 60)
clearChatBtn.BorderSizePixel = 0
clearChatBtn.Text = "🧹 Clear"
clearChatBtn.TextColor3 = Color3.fromRGB(240, 220, 200)
clearChatBtn.TextSize = 11
clearChatBtn.Font = Enum.Font.GothamMedium
clearChatBtn.Parent = statusBar
Instance.new("UICorner", clearChatBtn).CornerRadius = UDim.new(0, 4)

clearChatBtn.MouseButton1Click:Connect(function()
	-- Wipe the visible chat bubbles plus the model's history and session approvals.
	if scroll then
		for _, child in ipairs(scroll:GetChildren()) do
			if child:IsA("GuiObject") then child:Destroy() end
		end
	end
	bubbleOrder = 0
	messageHistory = {}
	sessionAllow = {}
	if addBubble then addBubble("🧹 Chat history cleared.", "tool") end
end)

-- Scroll area
scroll = Instance.new("ScrollingFrame")
scroll.Size = UDim2.new(1, -12, 1, -136)
scroll.Position = UDim2.new(0, 6, 0, 66)
scroll.BackgroundColor3 = Color3.fromRGB(20, 20, 28)
scroll.BorderSizePixel = 0
scroll.ScrollBarThickness = 4
scroll.ScrollBarImageColor3 = Color3.fromRGB(80, 80, 110)
scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
scroll.CanvasSize = UDim2.new(0, 0, 0, 0)
scroll.Parent = bg

local listLayout = Instance.new("UIListLayout")
listLayout.SortOrder = Enum.SortOrder.LayoutOrder
listLayout.Padding = UDim.new(0, 6)
listLayout.Parent = scroll

local scrollPad = Instance.new("UIPadding")
scrollPad.PaddingTop = UDim.new(0, 6); scrollPad.PaddingBottom = UDim.new(0, 6)
scrollPad.PaddingLeft = UDim.new(0, 6); scrollPad.PaddingRight = UDim.new(0, 6)
scrollPad.Parent = scroll

-- Input area
local inputBg = Instance.new("Frame")
inputBg.Size = UDim2.new(1, -12, 0, 62)
inputBg.Position = UDim2.new(0, 6, 1, -68)
inputBg.BackgroundColor3 = Color3.fromRGB(38, 38, 52)
inputBg.BorderSizePixel = 0
inputBg.Parent = bg
Instance.new("UICorner", inputBg).CornerRadius = UDim.new(0, 8)

local inputBox = Instance.new("TextBox")
inputBox.Size = UDim2.new(1, -114, 1, -8)
inputBox.Position = UDim2.new(0, 8, 0, 4)
inputBox.BackgroundTransparency = 1
inputBox.Text = ""
inputBox.PlaceholderText = "Ask anything about your game..."
inputBox.PlaceholderColor3 = Color3.fromRGB(90, 90, 110)
inputBox.TextColor3 = Color3.fromRGB(220, 220, 240)
inputBox.TextSize = 13
inputBox.Font = Enum.Font.Gotham
inputBox.TextXAlignment = Enum.TextXAlignment.Left
inputBox.TextYAlignment = Enum.TextYAlignment.Top
inputBox.MultiLine = true
inputBox.ClearTextOnFocus = false
inputBox.Parent = inputBg

local sendBtn = Instance.new("TextButton")
sendBtn.Size = UDim2.new(0, 56, 1, -8)
sendBtn.Position = UDim2.new(1, -62, 0, 4)
sendBtn.BackgroundColor3 = Color3.fromRGB(80, 110, 220)
sendBtn.BorderSizePixel = 0
sendBtn.Text = "Send"
sendBtn.TextColor3 = Color3.fromRGB(255, 255, 255)
sendBtn.TextSize = 13
sendBtn.Font = Enum.Font.GothamMedium
sendBtn.Parent = inputBg
Instance.new("UICorner", sendBtn).CornerRadius = UDim.new(0, 6)

-- 📎 Attach-file button, left of Send — same flow as the toolbar button / /attach.
local attachUiBtn = Instance.new("TextButton")
attachUiBtn.Size = UDim2.new(0, 32, 1, -8)
attachUiBtn.Position = UDim2.new(1, -100, 0, 4)
attachUiBtn.BackgroundColor3 = Color3.fromRGB(60, 60, 78)
attachUiBtn.BorderSizePixel = 0
attachUiBtn.Text = "📎"
attachUiBtn.TextColor3 = Color3.fromRGB(220, 220, 240)
attachUiBtn.TextSize = 16
attachUiBtn.Font = Enum.Font.GothamMedium
attachUiBtn.AutoButtonColor = true
attachUiBtn.Parent = inputBg
Instance.new("UICorner", attachUiBtn).CornerRadius = UDim.new(0, 6)
-- ── Chat state and bubble helpers ────────────────────────────

scrollToBottom = function()
	task.defer(function()
		if scroll.Parent then
			scroll.CanvasPosition = Vector2.new(0, scroll.AbsoluteCanvasSize.Y)
		end
	end)
end

local MAX_LABEL_CHARS = 4000  -- well under Roblox's cap, leaves headroom for wrapping

-- ── Markdown / Lua syntax highlighting helpers (used by addBubble) ──
local function escapeRichText(s)
	s = s:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
	return s
end

local LUA_KEYWORDS = {
	["and"]=true,["break"]=true,["do"]=true,["else"]=true,["elseif"]=true,
	["end"]=true,["false"]=true,["for"]=true,["function"]=true,["if"]=true,
	["in"]=true,["local"]=true,["nil"]=true,["not"]=true,["or"]=true,
	["repeat"]=true,["return"]=true,["then"]=true,["true"]=true,["until"]=true,
	["while"]=true,["continue"]=true,["self"]=true,
}

-- Tokenize Lua source and wrap tokens in RichText <font> colors.
local function highlightLua(code)
	local C_KW, C_STR, C_COM, C_NUM = "#569cd6", "#ce9178", "#6a9955", "#b5cea8"
	local out, i, n = {}, 1, #code
	local function emit(txt) out[#out+1] = escapeRichText(txt) end
	local function emitColor(color, txt)
		out[#out+1] = '<font color="' .. color .. '">' .. escapeRichText(txt) .. '</font>'
	end
	while i <= n do
		local two = code:sub(i, i+1)
		local c = code:sub(i, i)
		if code:sub(i, i+3) == "--[[" then
			local close = code:find("]]", i+4, true)
			local stop = close and (close+1) or n
			emitColor(C_COM, code:sub(i, stop)); i = stop + 1
		elseif two == "--" then
			local nl = code:find("\n", i, true)
			local stop = nl and (nl-1) or n
			emitColor(C_COM, code:sub(i, stop)); i = stop + 1
		elseif c == '"' or c == "'" then
			local j = i + 1
			while j <= n do
				local cj = code:sub(j, j)
				if cj == "\\" then j = j + 2
				elseif cj == c or cj == "\n" then break
				else j = j + 1 end
			end
			local stop = math.min(j, n)
			emitColor(C_STR, code:sub(i, stop)); i = stop + 1
		elseif two == "[[" then
			local close = code:find("]]", i+2, true)
			local stop = close and (close+1) or n
			emitColor(C_STR, code:sub(i, stop)); i = stop + 1
		elseif c:match("%d") then
			local j = i
			while j <= n and code:sub(j, j):match("[%w%.]") do j = j + 1 end
			emitColor(C_NUM, code:sub(i, j-1)); i = j
		elseif c:match("[%a_]") then
			local j = i
			while j <= n and code:sub(j, j):match("[%w_]") do j = j + 1 end
			local word = code:sub(i, j-1)
			if LUA_KEYWORDS[word] then emitColor(C_KW, word) else emit(word) end
			i = j
		else
			emit(c); i = i + 1
		end
	end
	return table.concat(out)
end

addBubble = function(text, kind)
	bubbleOrder = bubbleOrder + 1
	local color
	if kind == "user"      then color = Color3.fromRGB(48, 78, 160)
	elseif kind == "tool"  then color = Color3.fromRGB(60, 60, 40)
	elseif kind == "error" then color = Color3.fromRGB(100, 40, 40)
	else                        color = Color3.fromRGB(36, 36, 50)
	end

	local bubble = Instance.new("Frame")
	bubble.Size = UDim2.new(1, 0, 0, 0)
	bubble.AutomaticSize = Enum.AutomaticSize.Y
	bubble.BackgroundColor3 = color
	bubble.BorderSizePixel = 0
	bubble.LayoutOrder = bubbleOrder
	bubble.Parent = scroll
	Instance.new("UICorner", bubble).CornerRadius = UDim.new(0, 8)

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, 8);  pad.PaddingBottom = UDim.new(0, 8)
	pad.PaddingLeft = UDim.new(0, 10); pad.PaddingRight = UDim.new(0, 10)
	pad.Parent = bubble

	local list = Instance.new("UIListLayout")
	list.SortOrder = Enum.SortOrder.LayoutOrder
	list.Padding = UDim.new(0, 4)
	list.Parent = bubble

	local orderCounter = 0
	local firstLabel = nil
	local function nextOrder() orderCounter = orderCounter + 1; return orderCounter end

	local function chunkText(t)
		if #t <= MAX_LABEL_CHARS then return { t } end
		local chunks, remaining = {}, t
		while #remaining > MAX_LABEL_CHARS do
			local cut = remaining:sub(1, MAX_LABEL_CHARS):find("\n[^\n]*$")
			if not cut or cut < MAX_LABEL_CHARS * 0.5 then
				cut = remaining:sub(1, MAX_LABEL_CHARS):find(" [^ ]*$")
			end
			if not cut or cut < MAX_LABEL_CHARS * 0.5 then cut = MAX_LABEL_CHARS end
			table.insert(chunks, remaining:sub(1, cut))
			remaining = remaining:sub(cut + 1)
		end
		if #remaining > 0 then table.insert(chunks, remaining) end
		return chunks
	end

	-- Inline markdown (input MUST already be RichText-escaped)
	local function richInline(s)
		s = s:gsub("`([^`]+)`", '<font color="#e0a060">%1</font>')  -- inline code
		s = s:gsub("%*%*([^%*]+)%*%*", "<b>%1</b>")                   -- bold
		s = s:gsub("%*([^%*\n]+)%*", "<i>%1</i>")                     -- *italic*
		s = s:gsub("_([^_\n]+)_", "<i>%1</i>")                        -- _italic_
		return s
	end

	-- Defensive cleanup: models sometimes emit raw Roblox RichText / HTML tags
	-- (e.g. <font color="#...">, <b>, <i>) or stray tool-call tags directly in
	-- prose. escapeRichText would turn those into literal garbage text, so strip
	-- them here (keeping inner text). Inline `code` spans are protected first so
	-- a tag the user legitimately typed inside backticks survives.
	local function stripRawTags(s)
		local saved = {}
		s = s:gsub("`([^`]*)`", function(inner)
			saved[#saved + 1] = inner
			return "\1CB" .. #saved .. "\2"
		end)
		s = s:gsub("<font.->", ""):gsub("</font>", "")
		s = s:gsub("</?stroke.->", "")
		s = s:gsub("</?[biusBIUS]>", "")
		s = s:gsub("<br%s*/?>", "\n")
		s = s:gsub("</?tool_call>", ""):gsub("</?tool_results>", "")
		s = s:gsub("\1CB(%d+)\2", function(num)
			return "`" .. (saved[tonumber(num)] or "") .. "`"
		end)
		return s
	end

	-- Block-level markdown: headings (#) and bullet lists (- / *)
	local function richMarkdown(s)
		s = stripRawTags(s)
		s = escapeRichText(s)
		local lines = {}
		for line in (s .. "\n"):gmatch("([^\n]*)\n") do
			local hashes, htext = line:match("^(#+)%s+(.*)$")
			if hashes then
				local lvl = #hashes
				local size = (lvl <= 1 and 18) or (lvl == 2 and 16) or 15
				lines[#lines+1] = '<font size="' .. size .. '"><b>' .. richInline(htext) .. "</b></font>"
			else
				local b = line:match("^%s*[%-%*]%s+(.*)$")
				if b then
					lines[#lines+1] = "\u{2022} " .. richInline(b)
				else
					lines[#lines+1] = richInline(line)
				end
			end
		end
		return table.concat(lines, "\n")
	end

	local function addLabel(content, mono, rich)
		for _, chunk in ipairs(chunkText(content)) do
			local label = Instance.new("TextLabel")
			label.Size = UDim2.new(1, 0, 0, 0)
			label.AutomaticSize = Enum.AutomaticSize.Y
			label.BackgroundTransparency = 1
			label.TextColor3 = Color3.fromRGB(215, 215, 235)
			label.TextSize = 13
			label.Font = mono and Enum.Font.RobotoMono or Enum.Font.Gotham
			label.TextXAlignment = Enum.TextXAlignment.Left
			label.TextWrapped = true
			label.RichText = rich and true or false
			label.Text = rich and richMarkdown(chunk) or chunk
			label.LayoutOrder = nextOrder()
			label.Parent = bubble
			if not firstLabel then firstLabel = label end
		end
	end

	local function addProse(seg)
		if seg:match("%S") then addLabel(seg, false, true) end
	end

	local function addCodeBlock(code)
		for _, chunk in ipairs(chunkText(code)) do
			local box = Instance.new("Frame")
			box.Size = UDim2.new(1, 0, 0, 0)
			box.AutomaticSize = Enum.AutomaticSize.Y
			box.BackgroundColor3 = Color3.fromRGB(20, 20, 28)
			box.BorderSizePixel = 0
			box.LayoutOrder = nextOrder()
			box.Parent = bubble
			Instance.new("UICorner", box).CornerRadius = UDim.new(0, 6)
			local cpad = Instance.new("UIPadding")
			cpad.PaddingTop = UDim.new(0, 6); cpad.PaddingBottom = UDim.new(0, 6)
			cpad.PaddingLeft = UDim.new(0, 8); cpad.PaddingRight = UDim.new(0, 8)
			cpad.Parent = box

			local label = Instance.new("TextLabel")
			label.Size = UDim2.new(1, 0, 0, 0)
			label.AutomaticSize = Enum.AutomaticSize.Y
			label.BackgroundTransparency = 1
			label.TextColor3 = Color3.fromRGB(212, 212, 212)
			label.TextSize = 12
			label.Font = Enum.Font.RobotoMono
			label.TextXAlignment = Enum.TextXAlignment.Left
			label.TextWrapped = true
			label.RichText = true
			label.Text = highlightLua(chunk)
			label.Parent = box
			if not firstLabel then firstLabel = label end
		end
	end

	-- Assistant replies render as Markdown (``` code fences, headings, bullets,
	-- inline styling, syntax-highlighted code). Everything else stays plain.
	if kind == "assistant" then
		local pos, n = 1, #text
		while pos <= n do
			local fs, fe = text:find("```", pos, true)
			if not fs then
				addProse(text:sub(pos))
				break
			end
			if fs > pos then addProse(text:sub(pos, fs - 1)) end
			local cs, ce = text:find("```", fe + 1, true)
			if not cs then
				local code = text:sub(fe + 1)
				code = code:gsub("^[%w_%+%-%.#]*\n", ""):gsub("^\n", ""):gsub("\n+$", "")
				addCodeBlock(code)
				break
			end
			local code = text:sub(fe + 1, cs - 1)
			code = code:gsub("^[%w_%+%-%.#]*\n", ""):gsub("^\n", ""):gsub("\n+$", "")
			addCodeBlock(code)
			pos = ce + 1
		end
		if not firstLabel then addLabel(text, false, true) end
	else
		addLabel(text, (kind == "tool"), false)
	end

	scrollToBottom()
	return firstLabel  -- first label so callers can still mutate the bubble (e.g. thinking text)
end
-- ── Model picker dropdown ───────────────────────────────────
local modelDropdown
local function closeModelDropdown()
	if modelDropdown then modelDropdown:Destroy(); modelDropdown = nil end
end

local function openModelDropdown()
	if modelDropdown then closeModelDropdown(); return end

	local current = getCurrentModel()
	local itemHeight = 28
	local providerHeaderHeight = 44
	local totalHeight = 4

	for _, providerId in ipairs(PROVIDER_ORDER) do
		local p = PROVIDERS[providerId]
		totalHeight = totalHeight + providerHeaderHeight + (#p.models * itemHeight)
	end

	local height = math.min(totalHeight, 360)

	modelDropdown = Instance.new("Frame")
	modelDropdown.Size = UDim2.new(0, 280, 0, height)
	modelDropdown.Position = UDim2.new(0, 10, 0, 36)
	modelDropdown.BackgroundColor3 = Color3.fromRGB(24, 24, 34)
	modelDropdown.BorderSizePixel = 0
	modelDropdown.ZIndex = 10
	modelDropdown.Parent = bg
	Instance.new("UICorner", modelDropdown).CornerRadius = UDim.new(0, 6)

	local stroke = Instance.new("UIStroke")
	stroke.Color = Color3.fromRGB(60, 60, 80)
	stroke.Thickness = 1
	stroke.Parent = modelDropdown

	local scrollList = Instance.new("ScrollingFrame")
	scrollList.Size = UDim2.new(1, -4, 1, -4)
	scrollList.Position = UDim2.new(0, 2, 0, 2)
	scrollList.BackgroundTransparency = 1
	scrollList.BorderSizePixel = 0
	scrollList.ScrollBarThickness = 3
	scrollList.AutomaticCanvasSize = Enum.AutomaticSize.Y
	scrollList.CanvasSize = UDim2.new(0, 0, 0, 0)
	scrollList.ZIndex = 11
	scrollList.Parent = modelDropdown

	local layout = Instance.new("UIListLayout")
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.Padding = UDim.new(0, 2)
	layout.Parent = scrollList

	local order = 0

	for _, providerId in ipairs(PROVIDER_ORDER) do
		local p = PROVIDERS[providerId]
		order = order + 1

		local header = Instance.new("Frame")
		header.Size = UDim2.new(1, -4, 0, providerHeaderHeight)
		header.BackgroundColor3 = Color3.fromRGB(30, 30, 44)
		header.BorderSizePixel = 0
		header.LayoutOrder = order
		header.ZIndex = 12
		header.Parent = scrollList
		Instance.new("UICorner", header).CornerRadius = UDim.new(0, 4)

		local nameLabel = Instance.new("TextLabel")
		nameLabel.Size = UDim2.new(1, -12, 0, 22)
		nameLabel.Position = UDim2.new(0, 8, 0, 4)
		nameLabel.BackgroundTransparency = 1
		nameLabel.Text = p.display
		nameLabel.TextColor3 = Color3.fromRGB(230, 235, 255)
		nameLabel.TextSize = 13
		nameLabel.Font = Enum.Font.GothamBold
		nameLabel.TextXAlignment = Enum.TextXAlignment.Left
		nameLabel.ZIndex = 13
		nameLabel.Parent = header

		local statusLabel2 = Instance.new("TextLabel")
		statusLabel2.Size = UDim2.new(1, -12, 0, 16)
		statusLabel2.Position = UDim2.new(0, 8, 0, 24)
		statusLabel2.BackgroundTransparency = 1
		statusLabel2.Text = "● " .. getProviderStatusText(providerId)
		statusLabel2.TextColor3 = getProviderStatusColor(providerId)
		statusLabel2.TextSize = 11
		statusLabel2.Font = Enum.Font.Gotham
		statusLabel2.TextXAlignment = Enum.TextXAlignment.Left
		statusLabel2.ZIndex = 13
		statusLabel2.Parent = header

		for _, modelDef in ipairs(p.models) do
			order = order + 1
			local isSelected = (modelDef.id == current.id and providerId == current.provider)

			local item = Instance.new("TextButton")
			item.Size = UDim2.new(1, -4, 0, itemHeight - 4)
			item.BackgroundColor3 = isSelected and Color3.fromRGB(48, 60, 100) or Color3.fromRGB(36, 36, 50)
			item.BorderSizePixel = 0
			item.Text = (isSelected and "  ✓ " or "    ") .. modelDef.display
			item.TextColor3 = Color3.fromRGB(220, 220, 240)
			item.TextSize = 12
			item.Font = Enum.Font.Gotham
			item.TextXAlignment = Enum.TextXAlignment.Left
			item.AutoButtonColor = false
			item.LayoutOrder = order
			item.ZIndex = 12
			item.Parent = scrollList
			Instance.new("UICorner", item).CornerRadius = UDim.new(0, 4)

			item.MouseEnter:Connect(function()
				if not isSelected then item.BackgroundColor3 = Color3.fromRGB(50, 50, 70) end
			end)

			item.MouseLeave:Connect(function()
				if not isSelected then item.BackgroundColor3 = Color3.fromRGB(36, 36, 50) end
			end)

			item.MouseButton1Click:Connect(function()
				setCurrentModel(modelDef.id, providerId)
				refreshTitle()
				closeModelDropdown()
				messageHistory = {}
				sessionAllow = {}
				addBubble("🔄 Selected " .. p.display .. " · " .. modelDef.display ..
					". Conversation and approvals cleared.\n\nSelected provider/model will be used for chat.", "assistant")
			end)
		end
	end
end

titleBtn.MouseButton1Click:Connect(openModelDropdown)

-- Click anywhere else closes the dropdown
UserInputService.InputBegan:Connect(function(input, processed)
	if processed then return end
	if input.UserInputType == Enum.UserInputType.MouseButton1 and modelDropdown then
		-- Defer so the click on a menu item still registers
		task.defer(function()
			local mouse = UserInputService:GetMouseLocation()
			local pos = modelDropdown.AbsolutePosition
			local size = modelDropdown.AbsoluteSize
			local titlePos = titleBtn.AbsolutePosition
			local titleSize = titleBtn.AbsoluteSize
			local inDropdown = mouse.X >= pos.X and mouse.X <= pos.X + size.X
				and mouse.Y >= pos.Y and mouse.Y <= pos.Y + size.Y
			local inTitle = mouse.X >= titlePos.X and mouse.X <= titlePos.X + titleSize.X
				and mouse.Y >= titlePos.Y and mouse.Y <= titlePos.Y + titleSize.Y
			if not inDropdown and not inTitle then
				closeModelDropdown()
			end
		end)
	end
end)
-- ── Scanning ─────────────────────────────────────────────────
function doScan()
	statusLabel.Text = "⏳ Scanning game tree..."
	statusLabel.TextColor3 = Color3.fromRGB(220, 200, 100)
	rescanBtn.Text = "..."
	task.wait()
	local ok, dump = pcall(buildTreeDump)
	if ok then
		cachedTreeDump = dump
		local kb = math.floor(#dump / 1024)
		statusLabel.Text = "✅ Tree scanned — " .. kb .. " KB (scripts loaded on demand)"
		statusLabel.TextColor3 = Color3.fromRGB(140, 220, 140)
		messageHistory = {}
		print("[UnlimitedAI] Tree scanned: " .. #dump .. " chars")
	else
		statusLabel.Text = "❌ Scan failed: " .. tostring(dump):sub(1, 80)
		statusLabel.TextColor3 = Color3.fromRGB(220, 100, 100)
	end
	rescanBtn.Text = "🔄 Re-scan"
end

-- ── Send handler ─────────────────────────────────────────────
local sending = false

-- U2: toggle the Send button into a Stop button while a turn is running.
local function setSending(on)
	sending = on
	if on then
		sendBtn.Text = "Stop"
		sendBtn.BackgroundColor3 = Color3.fromRGB(170, 60, 60)
	else
		sendBtn.Text = "Send"
		sendBtn.BackgroundColor3 = Color3.fromRGB(80, 110, 220)
	end
end

local function sendToAI(userMessage)
	if sending then return end

	local usable, usableErr = isCurrentProviderUsable()
	if not usable then
		addBubble("❌ Selected provider is not ready: " .. tostring(usableErr) ..
			"\n\nPick another provider/model from the top-left model selector, or update Settings.", "error")
		return
	end

	resetCancel()

	setSending(true)

	addBubble("You: " .. userMessage, "user")

	-- auto_errors: if the user seems to report a problem, attach recent
	-- Output errors/warnings so the AI has them without an extra round-trip.
	local historyContent = userMessage
	do
		local lower = userMessage:lower()
		if lower:find("bug") or lower:find("error") or lower:find("broken")
			or lower:find("not work") or lower:find("doesn") or lower:find("won't")
			or lower:find("crash") or lower:find("fail") or lower:find("warning")
			or lower:find("isn't work") then
			local recent = readOutputRing(60)
			local errs = {}
			for _, line in ipairs(recent) do
				if line:find("%[ERROR%]") or line:find("%[WARN%]") then
					errs[#errs+1] = line
				end
			end
			while #errs > 15 do table.remove(errs, 1) end
			if #errs > 0 then
				historyContent = userMessage ..
					"\n\n[Auto-attached recent Output errors/warnings for context:]\n" ..
					table.concat(errs, "\n")
				addBubble("📎 Attached " .. #errs .. " recent error/warning line(s) for context.", "tool")
			end
		end
	end
	-- attachments: fold any files queued via the 📎 button into this message.
	if #pendingAttachments > 0 then
		local parts = {}
		for _, a in ipairs(pendingAttachments) do
			parts[#parts + 1] = "----- Attached file: " .. a.name ..
				(a.truncated and " (truncated)" or "") .. " -----\n" .. a.text
		end
		historyContent = historyContent ..
			"\n\n[Attached file content for context:]\n" .. table.concat(parts, "\n\n")
		addBubble("📎 Included " .. #pendingAttachments .. " attached file(s) with this message.", "tool")
		pendingAttachments = {}
	end
	table.insert(messageHistory, { role = "user", content = historyContent })

	-- ── Build a custom thinking bubble with collapsible tool log ──
	bubbleOrder = bubbleOrder + 1
	local thinkBubble = Instance.new("Frame")
	thinkBubble.Size = UDim2.new(1, 0, 0, 0)
	thinkBubble.AutomaticSize = Enum.AutomaticSize.Y
	thinkBubble.BackgroundColor3 = Color3.fromRGB(36, 36, 50)
	thinkBubble.BorderSizePixel = 0
	thinkBubble.LayoutOrder = bubbleOrder
	thinkBubble.Parent = scroll
	Instance.new("UICorner", thinkBubble).CornerRadius = UDim.new(0, 8)

	local pad = Instance.new("UIPadding")
	pad.PaddingTop = UDim.new(0, 8);  pad.PaddingBottom = UDim.new(0, 8)
	pad.PaddingLeft = UDim.new(0, 10); pad.PaddingRight = UDim.new(0, 10)
	pad.Parent = thinkBubble

	local list = Instance.new("UIListLayout")
	list.SortOrder = Enum.SortOrder.LayoutOrder
	list.Padding = UDim.new(0, 4)
	list.Parent = thinkBubble

	-- Round label (top line)
	local roundLabel = Instance.new("TextLabel")
	roundLabel.Size = UDim2.new(1, 0, 0, 18)
	roundLabel.BackgroundTransparency = 1
	roundLabel.Text = "💭 Thinking… (round 1)"
	roundLabel.TextColor3 = Color3.fromRGB(215, 215, 235)
	roundLabel.TextSize = 13
	roundLabel.Font = Enum.Font.Gotham
	roundLabel.TextXAlignment = Enum.TextXAlignment.Left
	roundLabel.LayoutOrder = 1
	roundLabel.Parent = thinkBubble

	-- token_meter: per-turn context size readout (dim, under the round line)
	local meterLabel = Instance.new("TextLabel")
	meterLabel.Size = UDim2.new(1, 0, 0, 14)
	meterLabel.BackgroundTransparency = 1
	meterLabel.Text = ""
	meterLabel.TextColor3 = Color3.fromRGB(120, 130, 150)
	meterLabel.TextSize = 11
	meterLabel.Font = Enum.Font.Gotham
	meterLabel.TextXAlignment = Enum.TextXAlignment.Left
	meterLabel.Visible = false
	meterLabel.LayoutOrder = 1
	meterLabel.Parent = thinkBubble

	-- Collapsible button (shown only once there's something to expand)
	local toolButton = Instance.new("TextButton")
	toolButton.Size = UDim2.new(0, 160, 0, 22)
	toolButton.BackgroundColor3 = Color3.fromRGB(50, 50, 70)
	toolButton.BorderSizePixel = 0
	toolButton.Text = "▾ Tool calls (0)"
	toolButton.TextColor3 = Color3.fromRGB(200, 210, 240)
	toolButton.TextSize = 12
	toolButton.Font = Enum.Font.GothamMedium
	toolButton.TextXAlignment = Enum.TextXAlignment.Left
	toolButton.AutoButtonColor = false
	toolButton.LayoutOrder = 2
	toolButton.Visible = false  -- shown when first tool call lands
	toolButton.Parent = thinkBubble
	Instance.new("UICorner", toolButton).CornerRadius = UDim.new(0, 4)
	local btnPad = Instance.new("UIPadding")
	btnPad.PaddingLeft = UDim.new(0, 8); btnPad.PaddingRight = UDim.new(0, 8)
	btnPad.Parent = toolButton

	-- Collapsible body that lists tool calls
	local toolBody = Instance.new("Frame")
	toolBody.Size = UDim2.new(1, 0, 0, 0)
	toolBody.AutomaticSize = Enum.AutomaticSize.Y
	toolBody.BackgroundTransparency = 1
	toolBody.LayoutOrder = 3
	toolBody.Visible = false
	toolBody.Parent = thinkBubble

	local bodyList = Instance.new("UIListLayout")
	bodyList.SortOrder = Enum.SortOrder.LayoutOrder
	bodyList.Padding = UDim.new(0, 2)
	bodyList.Parent = toolBody

	-- State
	local toolCalls = {}
	local expanded = false

	local function refreshButton()
		toolButton.Text = (expanded and "▴ " or "▾ ") .. "Tool calls (" .. #toolCalls .. ")"
	end

	toolButton.MouseButton1Click:Connect(function()
		expanded = not expanded
		toolBody.Visible = expanded
		refreshButton()
		scrollToBottom()
	end)

	toolButton.MouseEnter:Connect(function()
		toolButton.BackgroundColor3 = Color3.fromRGB(64, 64, 90)
	end)
	toolButton.MouseLeave:Connect(function()
		toolButton.BackgroundColor3 = Color3.fromRGB(50, 50, 70)
	end)

	-- The progress callback now does targeted updates rather than overwriting one label
	local function onProgress(msg)
		if not thinkBubble.Parent then return end

		-- token_meter: context-size readouts update the dim meter line
		if msg:find("📊", 1, true) then
			meterLabel.Text = msg
			meterLabel.Visible = true
			-- Thinking messages update the round label
		elseif msg:find("Thinking") then
			roundLabel.Text = msg
		else
			-- Tool call messages get appended to the dropdown
			table.insert(toolCalls, msg)

			local item = Instance.new("TextLabel")
			item.Size = UDim2.new(1, 0, 0, 0)
			item.AutomaticSize = Enum.AutomaticSize.Y
			item.BackgroundTransparency = 1
			item.Text = "  " .. #toolCalls .. ". " .. msg:gsub(" %(round %d+%)$", "")
			item.TextColor3 = Color3.fromRGB(190, 200, 220)
			item.TextSize = 12
			item.Font = Enum.Font.RobotoMono
			item.TextXAlignment = Enum.TextXAlignment.Left
			item.TextWrapped = true
			item.LayoutOrder = #toolCalls
			item.Parent = toolBody

			toolButton.Visible = true
			refreshButton()
		end

		task.wait()  -- let Roblox repaint
	end

	task.spawn(function()
		local finalText, err = runConversationTurn(messageHistory, onProgress)

		if thinkBubble and thinkBubble.Parent then
			thinkBubble:Destroy()
		end

		if err then
			addBubble("❌ " .. err, "error")
			if messageHistory[#messageHistory] and messageHistory[#messageHistory].role == "user" then
				table.remove(messageHistory)
			end
			-- U4: one-click Retry that re-sends the same message
			bubbleOrder = bubbleOrder + 1
			local retryWrap = Instance.new("Frame")
			retryWrap.Size = UDim2.new(1, 0, 0, 30)
			retryWrap.BackgroundTransparency = 1
			retryWrap.LayoutOrder = bubbleOrder
			retryWrap.Parent = scroll
			local retryBtn = Instance.new("TextButton")
			retryBtn.Size = UDim2.new(0, 120, 0, 26)
			retryBtn.Position = UDim2.new(0, 0, 0, 2)
			retryBtn.BackgroundColor3 = Color3.fromRGB(70, 90, 160)
			retryBtn.BorderSizePixel = 0
			retryBtn.Text = "↻ Retry"
			retryBtn.TextColor3 = Color3.fromRGB(255, 255, 255)
			retryBtn.TextSize = 12
			retryBtn.Font = Enum.Font.GothamMedium
			retryBtn.Parent = retryWrap
			Instance.new("UICorner", retryBtn).CornerRadius = UDim.new(0, 6)
			retryBtn.MouseButton1Click:Connect(function()
				if sending then return end
				retryBtn.Active = false
				retryBtn.AutoButtonColor = false
				retryBtn.Text = "↻ Retrying…"
				sendToAI(userMessage)
			end)
			scrollToBottom()
		elseif finalText then
			-- Strip any leftover <tool_call> tags from the visible text (shouldn't happen, but safe)
			local clean = finalText:gsub("<tool_call>.-</tool_call>", ""):match("^%s*(.-)%s*$")
			if clean ~= "" then
				addBubble(clean, "assistant")
			end
		end

		setSending(false)
		pcall(function() saveHistoryBlob(messageHistory) end)
	end)
end

-- ── Attach-file flow (📎 button / /attach) ───────────────────────
-- Prompts the user to pick a file, extracts its text via the API, and queues
-- it so it rides along with the next message they send to the AI.
local function promptAttach()
	widget.Enabled = true
	task.spawn(function()
		local StudioService = game:GetService("StudioService")
		local okP, file = pcall(function()
			return StudioService:PromptImportFile({
				"txt", "md", "csv", "tsv", "json", "xml", "html", "htm", "lua", "luau",
				"js", "ts", "py", "log", "yaml", "yml", "ini", "toml", "rtf",
				"pdf", "docx", "xlsx",
			})
		end)
		if not okP then
			addBubble("⚠️ File picker failed: " .. tostring(file), "error")
			scrollToBottom()
			return
		end
		if not file then return end  -- user cancelled

		addBubble("📎 Extracting " .. file.Name .. "…", "tool")
		scrollToBottom()
		local res, err = extractAttachment(file)
		if not res then
			addBubble("❌ Couldn't extract " .. file.Name .. ": " .. tostring(err), "error")
			scrollToBottom()
			return
		end
		pendingAttachments[#pendingAttachments + 1] = res
		addBubble(string.format(
			"📎 Attached %s — %d chars%s%s. It'll be included with your next message.",
			res.name, #res.text,
			res.parser and (" (" .. res.parser .. ")") or "",
			res.truncated and ", truncated" or ""
			), "tool")
		scrollToBottom()
	end)
end

-- ── Wire up buttons ──────────────────────────────────────────
-- Intercept slash commands (e.g. /goal) before they reach the AI.
local function handleUserInput(text)
	local cmd, rest = text:match("^/(%S+)%s*(.-)$")
	cmd = cmd and cmd:lower() or nil

	-- ── /goal checklist ───────────────────────────────────────────────
	if cmd == "goal" then
		rest = rest:match("^%s*(.-)%s*$")
		local sub, sargs = rest:match("^(%S+)%s*(.-)$")
		sub = sub and sub:lower() or ""
		local goals = getGoals()
		local function render()
			if #goals == 0 then return "🎯 No goals yet. Add one with /goal add <text>." end
			local lines = { "🎯 Goals:" }
			for i, g in ipairs(goals) do
				lines[#lines+1] = "  " .. (g.done and "✓" or "▢") .. " " .. i .. ". " .. tostring(g.text)
			end
			return table.concat(lines, "\n")
		end
		if rest == "" or sub == "list" then
			addBubble(render(), "assistant")
		elseif sub == "add" then
			local txt = sargs:match("^%s*(.-)%s*$")
			if txt == "" then
				addBubble("🎯 Usage: /goal add <text>", "assistant")
			else
				goals[#goals+1] = { text = txt, done = false }
				saveGoals(goals)
				addBubble("🎯 Added #" .. #goals .. ": " .. txt .. "\n\n" .. render(), "assistant")
			end
		elseif sub == "done" or sub == "undone" then
			local n = tonumber(sargs:match("%d+"))
			if not n or not goals[n] then
				addBubble("🎯 Usage: /goal " .. sub .. " <number>  (see /goal list)", "assistant")
			else
				goals[n].done = (sub == "done")
				saveGoals(goals)
				addBubble("🎯 Marked #" .. n .. " " .. (goals[n].done and "done ✓" or "not done") .. "\n\n" .. render(), "assistant")
			end
		elseif sub == "remove" or sub == "rm" or sub == "delete" then
			local n = tonumber(sargs:match("%d+"))
			if not n or not goals[n] then
				addBubble("🎯 Usage: /goal remove <number>  (see /goal list)", "assistant")
			else
				local removed = table.remove(goals, n)
				saveGoals(goals)
				addBubble("🎯 Removed: " .. tostring(removed.text) .. "\n\n" .. render(), "assistant")
			end
		elseif sub == "clear" or sub == "reset" or sub == "none" then
			saveGoals({})
			addBubble("🎯 All goals cleared.", "assistant")
		else
			goals[#goals+1] = { text = rest, done = false }  -- bare text = quick add
			saveGoals(goals)
			addBubble("🎯 Added #" .. #goals .. ": " .. rest .. "\n\n" .. render(), "assistant")
		end
		return

			-- ── Other slash commands ─────────────────────────────────────────
	elseif cmd == "plan" then
		rest = rest:match("^%s*(.-)%s*$")
		local sub, sargs = rest:match("^(%S+)%s*(.-)$")
		sub = sub and sub:lower() or ""
		sargs = sargs or ""
		if rest == "" or sub == "list" then
			addBubble(executeTool("list_plans", {}), "assistant")
		elseif sub == "read" or sub == "view" or sub == "show" then
			local nm = sargs:match("^%s*(.-)%s*$")
			if nm == "" then addBubble("🗂️ Usage: /plan read <name>", "assistant")
			else addBubble(executeTool("read_plan", { name = nm }), "assistant") end
		elseif sub == "new" or sub == "write" or sub == "set" then
			local nm, txt = sargs:match("^(%S+)%s*(.-)$")
			if not nm or nm == "" then
				addBubble("🗂️ Usage: /plan new <name> [text]", "assistant")
			elseif txt == nil or txt == "" then
				addBubble(executeTool("write_plan", { name = nm, content = "-- " .. nm .. "\n(empty - ask the AI to fill this in)" }), "assistant")
			else
				addBubble(executeTool("write_plan", { name = nm, content = txt }), "assistant")
			end
		elseif sub == "append" or sub == "add" then
			local nm, txt = sargs:match("^(%S+)%s*(.-)$")
			if not nm or nm == "" or txt == nil or txt == "" then
				addBubble("🗂️ Usage: /plan append <name> <text>", "assistant")
			else
				addBubble(executeTool("write_plan", { name = nm, content = txt, append = true }), "assistant")
			end
		elseif sub == "diff" then
			local nm = sargs:match("^%s*(.-)%s*$")
			if nm == "" then addBubble("🗂️ Usage: /plan diff <name>", "assistant")
			else addBubble(executeTool("diff_plan", { name = nm }), "assistant") end
		elseif sub == "restore" then
			local nm = sargs:match("^%s*(.-)%s*$")
			if nm == "" then addBubble("🗂️ Usage: /plan restore <name>", "assistant")
			else addBubble(executeTool("restore_plan", { name = nm }), "assistant") end
		elseif sub == "delete" or sub == "remove" or sub == "rm" then
			local nm = sargs:match("^%s*(.-)%s*$")
			if nm == "" then addBubble("🗂️ Usage: /plan delete <name>", "assistant")
			else task.spawn(function() addBubble(executeTool("delete_plan", { name = nm }), "assistant") end) end
		else
			addBubble("🗂️ /plan commands:\n  /plan list\n  /plan read <name>\n  /plan new <name> [text]\n  /plan append <name> <text>\n  /plan diff <name>\n  /plan restore <name>\n  /plan delete <name>", "assistant")
		end
		return

	elseif cmd == "attach" then
		promptAttach()
		return
	elseif cmd == "providers" or cmd == "provider" or cmd == "providercheck" then
		addBubble("🌐 Checking providers…", "tool")
		task.spawn(function()
			local report = checkAllProvidersOnline(false)
			addBubble(report, "assistant")
		end)
		return
	elseif cmd == "help" then
		addBubble(
			"💡 Commands:\n"
				.. "  /goal list | add <text> | done <n> | undone <n> | remove <n> | clear\n"
				.. "  /plan list | read <name> | new <name> | append <name> <text> | diff <name> | restore <name> | delete <name>\n"
				.. "  /model <name>   switch model\n"
				.. "  /providers      check provider online/offline status\n"
				.. "  /effort low|medium|high\n"
				.. "  /rescan         reload the game tree\n"
				.. "  /undo | /redo   undo or redo the last change\n"
				.. "  /checkpoint <name>   mark a rollback point\n"
				.. "  /rollback <name>     undo AI changes back to a checkpoint\n"
				.. "  /snapshot [name]     save script sources (revert later)\n"
				.. "     (or click the Snapshot toolbar button)\n"
				.. "  /log            list changes made this session\n"
				.. "  /history save | load | clear\n"
				.. "  /notes view | set <text> | append <text> | clear\n"
				.. "  /usage          session token estimate\n"
				.. "  /attach         pick a file; its text rides with your next message\n"
				.. "     (or click the Attach File toolbar button)\n"
				.. "  /clear          clear the conversation\n"
				.. "  /help           this list",
			"assistant")
		return
	elseif cmd == "clear" then
		messageHistory = {}
		sessionAllow = {}
		for _, child in ipairs(scroll:GetChildren()) do
			if child:IsA("Frame") then child:Destroy() end
		end
		bubbleOrder = 0
		addBubble("🧹 Conversation cleared.", "assistant")
		return
	elseif cmd == "rescan" then
		addBubble("🔄 Re-scanning game tree…", "assistant")
		task.spawn(doScan)
		return
	elseif cmd == "undo" then
		local ok = pcall(function() ChangeHistoryService:Undo() end)
		addBubble(ok and "↶ Undid the last change." or "⚠️ Nothing to undo.", "assistant")
		return
	elseif cmd == "effort" then
		local e = rest:match("^%s*(%S*)"):lower()
		if e == "low" or e == "medium" or e == "high" then
			CONFIG.EFFORT = e
			saveSettings({ effort = e })
			addBubble("⚙ Effort set to " .. e .. ".", "assistant")
		else
			addBubble("⚙ Usage: /effort low | medium | high (current: " .. tostring(CONFIG.EFFORT) .. ")", "assistant")
		end
		return
	elseif cmd == "model" then
		local q = rest:match("^%s*(.-)%s*$"):lower()
		if q == "" then
			local cm = getCurrentModel()
			addBubble("🤖 Current model: " .. cm.providerDisplay .. " · " .. cm.display .. "\nUsage: /model <name>", "assistant")
		else
			local match
			for _, m in ipairs(MODELS) do
				if m.display:lower():find(q, 1, true) or m.id:lower():find(q, 1, true) then
					match = m; break
				end
			end
			if match then
				setCurrentModel(match.id, match.provider)
				if refreshTitle then refreshTitle() end
				messageHistory = {}
				sessionAllow = {}
				addBubble("🔄 Switched to " .. match.providerDisplay .. " · " .. match.display ..
					". Conversation and approvals cleared.\n\nSelected provider/model will be used for chat.", "assistant")
			else
				addBubble("🤖 No model matches '" .. q .. "'. Click the model name to see the list.", "assistant")
			end
		end
		return
	elseif cmd == "redo" then
		local ok = pcall(function() ChangeHistoryService:Redo() end)
		addBubble(ok and "↷ Redid the last undone change." or "⚠️ Nothing to redo.", "assistant")
		return
	elseif cmd == "usage" then
		local pt = math.floor(sessionStats.promptChars / 4)
		local rt = math.floor(sessionStats.respChars / 4)
		addBubble(string.format(
			"📊 Session usage (estimate)\n"
				.. "  API calls:     %d\n"
				.. "  Prompt tokens: ~%d\n"
				.. "  Reply tokens:  ~%d\n"
				.. "  Total:         ~%d\n"
				.. "(~4 chars/token estimate. Your plan has unlimited tokens — this is just context visibility.)",
			sessionStats.calls, pt, rt, pt + rt), "assistant")
		return
	elseif cmd == "checkpoint" or cmd == "cp" then
		local nm = rest:match("^%s*(.-)%s*$")
		if nm == "" then nm = "cp" .. (1 + (function() local n = 0 for _ in pairs(checkpoints) do n = n + 1 end return n end)()) end
		checkpoints[nm] = aiWaypoints
		pcall(function() ChangeHistoryService:SetWaypoint("📍 checkpoint: " .. nm) end)
		addBubble("📍 Checkpoint '" .. nm .. "' set. Use /rollback " .. nm .. " to revert AI changes made after this point.", "assistant")
		return
	elseif cmd == "rollback" then
		local nm = rest:match("^%s*(.-)%s*$")
		if nm == "" then
			local names = {}
			for k in pairs(checkpoints) do names[#names + 1] = k end
			if #names == 0 then
				addBubble("↩️ No checkpoints set. Use /checkpoint <name> first.", "assistant")
			else
				addBubble("↩️ Usage: /rollback <name>\nCheckpoints: " .. table.concat(names, ", "), "assistant")
			end
			return
		end
		local snap = checkpoints[nm]
		if snap == nil then
			addBubble("↩️ No checkpoint named '" .. nm .. "'.", "assistant")
			return
		end
		local steps = aiWaypoints - snap
		if steps <= 0 then
			addBubble("↩️ Nothing to roll back — no AI changes recorded since checkpoint '" .. nm .. "'.", "assistant")
			return
		end
		local undone = 0
		for _ = 1, steps do
			local ok = pcall(function() ChangeHistoryService:Undo() end)
			if not ok then break end
			undone = undone + 1
		end
		aiWaypoints = snap
		addBubble("↩️ Rolled back " .. undone .. " AI change-step(s) to checkpoint '" .. nm .. "'.\n(Best-effort: manual Ctrl+Z/Redo since the checkpoint can affect accuracy.)", "assistant")
		return
	elseif cmd == "notes" then
		rest = rest:match("^%s*(.-)%s*$")
		local sub, sargs = rest:match("^(%S*)%s*(.-)$")
		sub = sub and sub:lower() or ""
		if rest == "" or sub == "view" or sub == "show" or sub == "list" then
			local n = getProjectNotes()
			addBubble(n ~= "" and ("📝 Project notes:\n" .. n) or "📝 No project notes yet. Set them with /notes set <text>.", "assistant")
		elseif sub == "set" then
			saveProjectNotes(sargs)
			addBubble("📝 Project notes saved (" .. #sargs .. " chars). The AI reads them every session.", "assistant")
		elseif sub == "append" or sub == "add" then
			local cur = getProjectNotes()
			local joined = (cur ~= "" and (cur .. "\n") or "") .. sargs
			saveProjectNotes(joined)
			addBubble("📝 Appended to project notes (now " .. #joined .. " chars).", "assistant")
		elseif sub == "clear" or sub == "reset" then
			saveProjectNotes("")
			addBubble("📝 Project notes cleared.", "assistant")
		else
			addBubble("📝 Usage: /notes view | set <text> | append <text> | clear", "assistant")
		end
		return
	elseif cmd == "snapshot" or cmd == "snap" then
		local nm = rest:match("^%s*(.-)%s*$")
		local r = executeTool("create_snapshot", nm ~= "" and { name = nm } or {})
		addBubble(r, "assistant")
		return
	elseif cmd == "log" or cmd == "changes" then
		addBubble(executeTool("get_changes", {}), "assistant")
		return
	elseif cmd == "history" then
		local sub = rest:match("^%s*(%S*)"):lower()
		if sub == "save" then
			saveHistoryBlob(messageHistory)
			addBubble("💬 Saved " .. #messageHistory .. " message(s).", "assistant")
		elseif sub == "load" or sub == "restore" then
			local blob = loadHistoryBlob()
			if not blob or #blob == 0 then
				addBubble("💬 No saved conversation to load.", "assistant")
			else
				messageHistory = blob
				for _, m in ipairs(blob) do
					if m.role == "user" and m.content:sub(1, 14) ~= "<tool_results>" then
						addBubble(m.content, "user")
					elseif m.role == "assistant" then
						local clean = m.content:gsub("<tool_call>.-</tool_call>", ""):match("^%s*(.-)%s*$")
						if clean ~= "" then addBubble(clean, "assistant") end
					end
				end
				addBubble("💬 Restored " .. #blob .. " message(s) of context.", "assistant")
			end
		elseif sub == "clear" then
			plugin:SetSetting("unlimited_history", nil)
			addBubble("💬 Saved conversation cleared.", "assistant")
		else
			addBubble("💬 Usage: /history save | load | clear", "assistant")
		end
		return
	end

	sendToAI(text)
end

local function onSend()
	local text = inputBox.Text:match("^%s*(.-)%s*$")
	if text == "" then return end
	inputBox.Text = ""
	handleUserInput(text)
end

sendBtn.MouseButton1Click:Connect(function()
	if sending then
		requestCancel()
		sendBtn.Text = "Stopping…"
	else
		onSend()
	end
end)

-- ── Enter to send, Shift+Enter to newline ────────────────────
inputBox.FocusLost:Connect(function(enterPressed, _inputObject)
	-- Only act when Enter caused the focus loss (clicking away should do nothing)
	if not enterPressed then return end

	-- Shift+Enter → insert a newline and refocus, don't send
	local shifted = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift)
		or UserInputService:IsKeyDown(Enum.KeyCode.RightShift)
	if shifted then
		local current = inputBox.Text
		-- Append newline at end of current text (Roblox stripped the newline that caused FocusLost)
		inputBox.Text = current .. "\n"
		task.defer(function()
			if inputBox.Parent then
				inputBox:CaptureFocus()
				-- Cursor goes to end automatically when refocusing after a text mutation
			end
		end)
		return
	end

	-- Plain Enter → send
	local text = inputBox.Text:match("^%s*(.-)%s*$")
	if not text or text == "" then
		-- Empty box — refocus so the user can keep typing without a click
		task.defer(function()
			if inputBox.Parent then inputBox:CaptureFocus() end
		end)
		return
	end

	inputBox.Text = ""
	handleUserInput(text)

	-- Refocus so the user can immediately type the next message
	task.defer(function()
		if inputBox.Parent then inputBox:CaptureFocus() end
	end)
end)

rescanBtn.MouseButton1Click:Connect(function() task.spawn(doScan) end)
toggleBtn.Click:Connect(function() widget.Enabled = not widget.Enabled end)
settingsBtn.Click:Connect(promptApiKey)
attachBtn.Click:Connect(promptAttach)
attachUiBtn.MouseButton1Click:Connect(promptAttach)
snapshotBtn.Click:Connect(function()
	widget.Enabled = true
	local r = executeTool("create_snapshot", { name = os.date("!manual-%H%M%S") })
	addBubble(r, "assistant")
	scrollToBottom()
end)

-- ── First-run welcome ────────────────────────────────────────
-- Refresh banner in case bypass state was already set
if refreshBypassBanner then refreshBypassBanner() end

-- Start a lightweight provider status check in the background so the model
-- picker's Online/Offline labels are based on a real ping.
task.spawn(function()
	checkAllProvidersOnline(false)
end)

-- ── Plugin helper objects (Plans / SystemPrompt folders, test rig) ──────
-- Skipped entirely when the user enabled "don't create helper objects" in
-- Settings — features depending on them are disabled until it's turned back
-- off. One quiet note is shown so the user remembers why those features are
-- unavailable.
if getSetting("disable_helper_objects") then
	addBubble("ℹ️ Helper-object creation is OFF (Settings). Plan storage, " ..
		"read_system_prompt, and the default test rig are disabled until you " ..
		"re-enable it.", "assistant")
else

	-- ── Ensure the Plans storage folder exists on load ──────────────────
	do
		local plansFolder, perr = ensurePlansFolder()
		if plansFolder then
			addBubble("🗂️ Plans folder ready at " .. plansFolder:GetFullName() ..
				". I can read and generate plans here — list_plans / read_plan / write_plan.", "assistant")
		else
			addBubble("⚠️ Could not set up the Plans folder: " .. tostring(perr), "assistant")
		end
	end

	-- ── Materialize the system prompt so the AI (and you) can reread it ──────────
	do
		local spFolder, sperr = ensureSystemPromptFolder()
		if spFolder then
			local ok = pcall(function()
				local doc = spFolder:FindFirstChild(CONFIG.SYSTEM_PROMPT_SCRIPT_NAME)
				if doc and not doc:IsA("ModuleScript") then doc:Destroy(); doc = nil end
				if not doc then
					doc = Instance.new("ModuleScript")
					doc.Name = CONFIG.SYSTEM_PROMPT_SCRIPT_NAME
					doc.Parent = spFolder
				end
				-- Only rewrite when it changed, to avoid needlessly dirtying the place.
				if doc.Source ~= TOOL_SYSTEM then doc.Source = TOOL_SYSTEM end
			end)
			if ok then
				addBubble("📜 System prompt saved at " .. spFolder:GetFullName() .. "." ..
					CONFIG.SYSTEM_PROMPT_SCRIPT_NAME .. ". I can reread it anytime with read_system_prompt.", "assistant")
			else
				addBubble("⚠️ Could not write the system prompt copy.", "assistant")
			end
		else
			addBubble("⚠️ Could not set up the SystemPrompt folder: " .. tostring(sperr), "assistant")
		end
	end

	-- ── Ensure a default test rig exists for the AI to animate ───────────────
	do
		local rig, rerr, justCreated = ensureTestRig()
		if rig then
			if justCreated then
				addBubble("🧍 Spawned a default " .. CONFIG.RIG_TYPE .. " rig at " .. rig:GetFullName() ..
					" for animation work. Try list_rig_joints on it, or spawn_rig for more. " ..
					"@character / @player still works too, for animating the real player.", "assistant")
			end
		else
			addBubble("⚠️ Could not spawn the default test rig: " .. tostring(rerr), "assistant")
		end
	end

end  -- end of the "disable_helper_objects" guard

if not isCurrentProviderUsable() then
	addBubble(
		"👋 Welcome to Unlimited AI!\n\n"
			.. "The selected provider/model is not ready yet.\n"
			.. "Click the model name in the top-left to choose a provider, or click ⚙ Settings to update provider keys/URLs.\n"
			.. "Then click 🔄 Re-scan to load your game tree.\n\n"
			.. "DeepCode can work with an empty key. Unlimited/Homelander/GPT keys depend on their provider settings.",
		"assistant"
	)
else
	addBubble(
		"👋 Unlimited AI ready (" .. getCurrentModel().providerDisplay .. " · " .. getCurrentModel().display .. ").\n\n"
			.. "Click the model name in the top-left to switch models.\n"
			.. "Tree-only scan is loaded — scripts and properties are fetched on demand.\n"
			.. "Destructive edits create undo waypoints, so Ctrl+Z reverts them.\n"
			.. "Re-scan whenever you rename, move, or add instances.\n"
			.. "Track goals with /goal add <text> and design notes with /plan — type /help for all commands.",
		"assistant"
	)
	do
		local goals = getGoals()
		local open = 0
		for _, g in ipairs(goals) do if not g.done then open = open + 1 end end
		if #goals > 0 then
			addBubble("🎯 " .. open .. " open goal(s). Type /goal list to view or /goal add <text>.", "assistant")
		end
	end
	do
		local pf = getPlansFolderIfExists()
		if pf then
			local pc = 0
			for _, c in ipairs(pf:GetChildren()) do if c:IsA("ModuleScript") then pc = pc + 1 end end
			if pc > 0 then
				addBubble("🗂️ " .. pc .. " saved plan(s). Type /plan list to view.", "assistant")
			end
		end
	end
	task.spawn(doScan)
end

print("[UnlimitedAI] Plugin loaded.")]]></ProtectedString>
			<bool name="Disabled">false</bool>
			<Content name="LinkedSource"><null></null></Content>
			<token name="RunContext">0</token>
			<string name="ScriptGuid">{3043F71E-D6D3-43E6-8754-E4AB44AC11BA}</string>
			<BinaryString name="AttributesSerialize"></BinaryString>
			<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
			<bool name="DefinesCapabilities">false</bool>
			<string name="Name">UnlimitedSurfAI</string>
			<int64 name="SourceAssetId">-1</int64>
			<BinaryString name="Tags"></BinaryString>
		</Properties>
	</Item>
</roblox>