diff --git a/vector/apps/web/app/api/assets/search/route.ts b/vector/apps/web/app/api/assets/search/route.ts index 080519d..58e195f 100644 --- a/vector/apps/web/app/api/assets/search/route.ts +++ b/vector/apps/web/app/api/assets/search/route.ts @@ -25,7 +25,7 @@ export async function GET(req: Request) { const tags = parseTags(searchParams) const logQuery = query.replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80) const logTags = tags.map((t) => t.slice(0, 24)).join('|') - const limit = Math.max(1, Math.min(50, Number(searchParams.get('limit') || '8'))) + const limit = Math.max(1, Math.min(60, Number(searchParams.get('limit') || '8'))) const override = process.env.CATALOG_API_URL?.trim() const provider = !override || override.toLowerCase() === 'roblox' ? 'roblox' : 'proxy' const t0 = Date.now() @@ -48,4 +48,3 @@ export async function GET(req: Request) { }) } } - diff --git a/vector/apps/web/app/api/proposals/[id]/apply/route.ts b/vector/apps/web/app/api/proposals/[id]/apply/route.ts index 197a9fb..16bfd5d 100644 --- a/vector/apps/web/app/api/proposals/[id]/apply/route.ts +++ b/vector/apps/web/app/api/proposals/[id]/apply/route.ts @@ -42,8 +42,8 @@ export async function POST(req: Request, ctx: { params: { id: string } }) { const opKind = typeof (body as any)?.op === 'string' ? String((body as any).op) : undefined const failed = (body as any)?.ok === false if (wf && isAsset && failed) { - const fallback = 'CATALOG_UNAVAILABLE Asset search/insert failed. Create the requested objects manually using create_instance/set_properties or Luau edits.' - try { pushChunk(wf, 'fallback.asset manual_required') } catch (e) { console.error('Failed to push chunk for asset fallback', e) } + const fallback = 'CATALOG_UNAVAILABLE Asset search/insert failed. Consider manual geometry or alternative assets.' + try { pushChunk(wf, 'fallback.asset manual_suggest') } catch (e) { console.error('Failed to push chunk for asset fallback', e) } updateTaskState(wf, (state) => { state.history.push({ role: 'system', content: fallback + (opKind ? ` op=${opKind}` : ''), at: Date.now() }) }) @@ -66,6 +66,25 @@ export async function POST(req: Request, ctx: { params: { id: string } }) { ) } } + // Log asset operations verbosely for terminal diagnostics + if (typeof (body as any)?.type === 'string' && (body as any).type === 'asset_op') { + const opKind = typeof (body as any)?.op === 'string' ? String((body as any).op) : 'unknown' + const ok = (body as any)?.ok === true + const assetId = (body as any)?.assetId + const parentPath = typeof (body as any)?.parentPath === 'string' ? String((body as any)?.parentPath) : undefined + const insertedPath = typeof (body as any)?.insertedPath === 'string' ? String((body as any)?.insertedPath) : undefined + const query = typeof (body as any)?.query === 'string' ? String((body as any)?.query) : undefined + const error = typeof (body as any)?.error === 'string' ? String((body as any)?.error) : undefined + const base = `[proposals.apply] asset_op op=${opKind}` + + (assetId != null ? ` id=${assetId}` : '') + + (parentPath ? ` parent=${parentPath}` : '') + + (query ? ` query="${query}"` : '') + if (ok) { + console.log(`${base} ok inserted=${insertedPath || 'n/a'}`) + } else { + console.warn(`${base} failed error=${error || 'unknown'}`) + } + } console.log( `[proposals.apply] id=${id} ok=${!!after} workflowId=${after?.workflowId || 'n/a'} payloadKeys=${Object.keys(body || {}).length}`, ) diff --git a/vector/apps/web/lib/catalog/search.ts b/vector/apps/web/lib/catalog/search.ts index 235a9de..bc1e3c3 100644 --- a/vector/apps/web/lib/catalog/search.ts +++ b/vector/apps/web/lib/catalog/search.ts @@ -1,4 +1,13 @@ -export type CatalogItem = { id: number; name: string; creator: string; type: string; thumbnailUrl?: string } +export type CatalogItem = { + id: number + name: string + creator: string + type: string + thumbnailUrl?: string + creatorId?: number + creatorType?: string + isVerified?: boolean +} type CatalogSearchOptions = { tags?: string[] @@ -6,7 +15,7 @@ type CatalogSearchOptions = { const ROBLOX_CATALOG_URL = 'https://catalog.roblox.com/v1/search/items/details' const ROBLOX_THUMBNAIL_URL = 'https://thumbnails.roblox.com/v1/assets' -const ROBLOX_ALLOWED_LIMITS = [10, 28, 30] as const +const ROBLOX_ALLOWED_LIMITS = [10, 28, 30, 60] as const const TAG_CATEGORY_MAP: Record = { audio: 'Audio', @@ -52,7 +61,7 @@ function normalizeString(value?: string | null): string | undefined { } function pickRobloxLimit(limit: number): number { - const safe = Math.max(1, Math.min(50, Number.isFinite(limit) ? limit : 8)) + const safe = Math.max(1, Math.min(60, Number.isFinite(limit) ? limit : 8)) for (const allowed of ROBLOX_ALLOWED_LIMITS) { if (safe <= allowed) return allowed } @@ -98,7 +107,7 @@ async function fetchRobloxThumbnails(ids: number[]): Promise async function fetchFromRoblox(query: string, limit: number, opts?: CatalogSearchOptions): Promise { const trimmedQuery = query?.trim() ?? '' - const desiredLimit = Math.max(1, Math.min(50, Number.isFinite(limit) ? limit : 8)) + const desiredLimit = Math.max(1, Math.min(60, Number.isFinite(limit) ? limit : 8)) const requestLimit = pickRobloxLimit(desiredLimit) const category = deriveCategory(opts?.tags) const params = new URLSearchParams({ @@ -109,6 +118,7 @@ async function fetchFromRoblox(query: string, limit: number, opts?: CatalogSearc SortType: '3', }) // Optional: restrict to free assets only when explicitly requested + // Default free-only OFF to broaden results; env can enable explicitly const freeOnly = String(process.env.CATALOG_FREE_ONLY || '0') === '1' if (freeOnly) { // Prefer Roblox sales filter for free items; explicit price filters often 0-out results @@ -156,12 +166,19 @@ async function fetchFromRoblox(query: string, limit: number, opts?: CatalogSearc for (const entry of sliced) { const id = Number(entry?.id) if (!Number.isFinite(id)) continue + const creatorIdRaw = entry?.creatorTargetId ?? entry?.creatorId + const creatorId = typeof creatorIdRaw === 'number' ? creatorIdRaw : Number(creatorIdRaw) + const creatorType = typeof entry?.creatorType === 'string' ? entry.creatorType : undefined + const isVerified = Boolean(entry?.creatorHasVerifiedBadge ?? entry?.hasVerifiedBadge) items.push({ id, name: typeof entry?.name === 'string' ? entry.name : `Asset ${id}`, creator: typeof entry?.creatorName === 'string' ? entry.creatorName : 'Unknown', type: assetTypeLabel(entry?.assetType), thumbnailUrl: thumbMap.get(id), + creatorId: Number.isFinite(creatorId) ? Number(creatorId) : undefined, + creatorType, + isVerified, }) } return items @@ -207,7 +224,7 @@ async function fetchFromCreatorStoreToolbox(query: string, limit: number, opts?: // Broaden results by allowing all creators (verified and non-verified) url.searchParams.set('includeOnlyVerifiedCreators', 'false') // Free-only via price caps in cents - if (String(process.env.CATALOG_FREE_ONLY || '0') === '1') { + if (String(process.env.CATALOG_FREE_ONLY || '1') === '1') { url.searchParams.set('minPriceCents', '0') url.searchParams.set('maxPriceCents', '0') } @@ -244,7 +261,20 @@ async function fetchFromCreatorStoreToolbox(query: string, limit: number, opts?: // Toolbox imagePreviewAssets are asset ids; thumbnails route might be needed separately. // Use undefined here; downstream can fetch thumbnails by id if needed. } - items.push({ id, name, creator, type, thumbnailUrl }) + const creatorIdRaw = entry?.creator?.id ?? entry?.creatorId + const creatorId = typeof creatorIdRaw === 'number' ? creatorIdRaw : Number(creatorIdRaw) + const creatorType = typeof entry?.creator?.type === 'string' ? entry.creator.type : undefined + const isVerified = Boolean(entry?.creator?.hasVerifiedBadge ?? entry?.creatorHasVerifiedBadge) + items.push({ + id, + name, + creator, + type, + thumbnailUrl, + creatorId: Number.isFinite(creatorId) ? Number(creatorId) : undefined, + creatorType, + isVerified, + }) } return items.slice(0, Math.max(1, Math.min(50, limit || 8))) } finally { diff --git a/vector/apps/web/lib/orchestrator/README.md b/vector/apps/web/lib/orchestrator/README.md index d48377a..f5986c9 100644 --- a/vector/apps/web/lib/orchestrator/README.md +++ b/vector/apps/web/lib/orchestrator/README.md @@ -10,5 +10,9 @@ Coordinates multi-step tasks, provider execution, prompt assembly, and proposal - `taskState.ts` – State tracking for tasks. - `proposals.ts` / `autoApprove.ts` – Proposal generation and auto-approval logic. +## Behaviour Highlights +- Catalog fallback switches the task into **manual mode**. Once enabled, asset search/insert tools are blocked until manual geometry (Parts + Luau) is produced or the user explicitly re-enables catalog usage. +- When a plan already exists, additional `` calls must reuse the same steps; changes require ``. + ## Extending Providers Add a new file under `providers/` exporting a standardized interface (see existing providers for shape). diff --git a/vector/apps/web/lib/orchestrator/index.ts b/vector/apps/web/lib/orchestrator/index.ts index 308e243..0ebe3c1 100644 --- a/vector/apps/web/lib/orchestrator/index.ts +++ b/vector/apps/web/lib/orchestrator/index.ts @@ -132,6 +132,7 @@ import { getSceneProperties, applyObjectOpsPreview, hydrateSceneSnapshot, + normalizeInstancePath, } from './sceneGraph' type ProviderMode = 'openrouter' | 'gemini' | 'bedrock' | 'nvidia' @@ -347,106 +348,101 @@ function determineProvider(opts: { input: ChatInput; modelOverride?: string | nu const PROMPT_SECTIONS = [ `You are Vector, a Roblox Studio copilot.`, - `Core rules + `Core rules (guidance only) - One tool per turn: emit EXACTLY ONE tool tag. It must be the last output (ignoring trailing whitespace). Wait for the tool result before continuing. -- Proposal-first and undoable: never change code/Instances outside a tool; keep each step small and reviewable. -- Plan when work spans multiple steps. For a single obvious action you may act without . -- Keep responses to that single tool tag (no markdown or invented tags). Optional brief explanatory text may appear before the tag when allowed.` , - `Planning details -- For non-trivial tasks, your MUST list detailed, tool-specific steps (8–15 typical): include the tool name, exact target (class/path/name), and the intended outcome. Example: "Create Model 'Base' under game.Workspace", "Search assets query='barracks'", "Insert asset 12345 under game.Workspace.Base", "Set CFrame for 'Gate' to (0,0,50)", "Open or create Script 'BaseBuilder'", "Show diff to add idempotent Luau".`, - `Default Script Policy -- Whenever you create, modify, or insert Instances (create_instance/set_properties/rename_instance/delete_instance/insert_asset/generate_asset_3d), you must author Luau that rebuilds the result before completing. -- Preferred flow: open_or_create_script → show_diff (or apply_edit when already previewed). Scripts must be valid, idempotent, and set Anchored/props explicitly. -- Skip Luau only when the user explicitly opts out (e.g., "geometry only", "no script", "no code"). Otherwise completion is blocked.`, - `Tool calls - - ... - -- Omit optional params you don’t know. -- JSON goes inside the tag as strict JSON (double quotes, no trailing commas). -- For show_diff/apply_edit the tag MUST contain a JSON array of edit objects (example: [{"start":{...},"end":{...},"text":"..."}]); never pass a plain string. -- For show_diff/apply_edit when using , every files[i].edits must also be that same JSON array (no strings, no code fences).`, - `Encoding & hygiene -- Strings/numbers are literal. JSON must not be wrapped in quotes or code fences. -- Paths use GetFullName() with brackets for special characters. -- Attributes use "@Name" keys. -- Edits are 0-based, non-overlapping, ≤20 edits and ≤2000 inserted chars.`, - `Assets & 3D - - Prefer search_assets → insert_asset for props/models. Use create_instance only for simple primitive geometry or when catalog search fails/disabled. - - Composite scenes (e.g., park, plaza, town square): it is acceptable to add multiple distinct assets (e.g., trees, benches, fountain, lights) as separate search_assets → insert_asset steps in the plan. Keep it concise: 2–4 categories initially, each with limit ≤ 6. - - Single‑subject builds (e.g., hospital, watch tower): avoid adding unrelated props unless requested. If a container model (e.g., game.Workspace.Hospital) already exists, do not recreate it; prefer inserting exactly one primary asset (e.g., a hospital building) under a sensible child like "Imported", or add obviously missing interior kits/furnishings. - - In Auto mode (autoApply=true), prefer choosing a single best match and inserting it directly without listing. Do not insert multiples unless the user explicitly asks for more than one. - - search_assets limit ≤ 6 unless the user asks. Include helpful tags. - - insert_asset defaults parentPath to game.Workspace if unknown. Before creating or inserting, inspect existing children (list_children) and skip duplicates when names/roles already exist.`, - `Scene building - - Always think through the layout before acting: use to outline the main structures, then execute steps one tool at a time. - - Inspect what already exists. If nothing is selected, call on game.Workspace (depth 1–2) to inventory the scene; also use and . Reuse or extend Models instead of duplicating them. - - Build geometry iteratively with create_instance/set_properties, anchoring parts and setting Size/CFrame so progress is visible in Workspace. - - Only switch to scripting when the user explicitly wants reusable code or behaviour. Otherwise stay in direct manipulation mode.`, - `Quality checks -- Derive a short checklist from the user prompt and track progress (optionally via ). -- Do not call until every checklist item exists and the matching Luau is written (unless the user opted out). -- Created Models must contain anchored, visible parts or code that produces them.`, - `Validation & recovery -- On VALIDATION_ERROR, retry the SAME tool once with corrected args (no commentary or tool switching). -- If you would otherwise reply with no tool, either choose exactly one tool or finish with .`, -String.raw`Quick examples -Detailed plan (assets-first) - - [ - "Create Model 'MilitaryBase' under game.Workspace", - "Search assets query='watch tower' tags=['model'] limit=6", - "Insert asset under game.Workspace.MilitaryBase", - "Search assets query='barracks' tags=['model'] limit=6", - "Insert asset under game.Workspace.MilitaryBase", - "Search assets query='fence' tags=['model'] limit=6", - "Insert asset under game.Workspace.MilitaryBase", - "Set properties (Anchored, CFrame) to arrange towers, barracks, fence perimeter", - "Open or create Script 'BaseBuilder' in game.ServerScriptService", - "Show diff to add idempotent Luau that rebuilds the base" - ] - - -Insert an asset (preferred) +- Default to run_command for actions (create/modify/insert). Keep list_children for context and start_plan/update_plan to outline work. +- Keep each step small and reviewable; never modify outside a tool.`, + `Commands and tools (concise) +- run_command (default for actions). Verbs: + • create_model parent="..." name="..." + • create_part parent="..." name="..." size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 + • set_props path="..." Anchored=1 size=... cframe=... + • rename path="..." newName="..." | delete path="..." + • insert_asset assetId=123456 parent="..." (disabled in manual mode) +- list_children: inventory scene; include parentPath and depth when helpful. +- start_plan / update_plan: create and maintain a single plan; use update_plan to adjust. +- open_or_create_script / show_diff: author idempotent Luau when needed. +- complete / final_message / message: summaries and updates.`, +String.raw`Command cheat-sheet (guidance only) +Inspect workspace (depth=2) + + game.Workspace + 2 + + +Create model + floor/roof via run_command + + create_model parent="game.Workspace" name="Hospital" + + + create_part parent="game.Workspace.Hospital" name="Floor" size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 + + + create_part parent="game.Workspace.Hospital" name="Roof" size=42,1,42 cframe=0,11,0 material=Slate anchored=1 + + +Walls (repeat with adjusted CFrame and Size) + + create_part parent="game.Workspace.Hospital" name="WallFront" size=40,10,1 cframe=0,5.5,-19.5 material=Brick anchored=1 + + +Builder script (idempotent) + + game.ServerScriptService + HospitalBuilder + + + game.ServerScriptService.HospitalBuilder + [{"start":{"line":0,"character":0},"end":{"line":0,"character":0},"text":"local Workspace = game:GetService('Workspace')\nlocal function ensureModel(name)\n\tlocal m = Workspace:FindFirstChild(name)\n\tif not m then\n\tm = Instance.new('Model')\n\tm.Name = name\n\tm.Parent = Workspace\n\tend\n\treturn m\nend\nlocal function ensurePart(parent, name, size, cf, mat)\n\tlocal p = parent:FindFirstChild(name)\n\tif not p then\n\tp = Instance.new('Part')\n\tp.Anchored = true\n\tp.Name = name\n\tp.Parent = parent\n\tend\n\tp.Size = size\n\tp.CFrame = cf\n\tif mat then p.Material = mat end\n\treturn p\nend\nlocal hospital = ensureModel('Hospital')\nensurePart(hospital, 'Floor', Vector3.new(40,1,40), CFrame.new(0,0.5,0), Enum.Material.Concrete)\nensurePart(hospital, 'WallFront', Vector3.new(40,10,1), CFrame.new(0,5.5,-19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallBack', Vector3.new(40,10,1), CFrame.new(0,5.5,19.5), Enum.Material.Brick)\nensurePart(hospital, 'WallLeft', Vector3.new(1,10,40), CFrame.new(-19.5,5.5,0), Enum.Material.Brick)\nensurePart(hospital, 'WallRight', Vector3.new(1,10,40), CFrame.new(19.5,5.5,0), Enum.Material.Brick)\nensurePart(hospital, 'Roof', Vector3.new(42,1,42), CFrame.new(0,11,0), Enum.Material.Slate)\n"}] + + +Catalog search + insert - oak tree - ["tree","nature"] + hospital bed + ["model","bed","hospital"] 6 - - - 123456789 - game.Workspace - - -Build simple geometry (generic) + + insert_asset assetId=125013769 parent="game.Workspace.Hospital" + + +Manual shell (copy block for Floor + 4 walls + roof) + + create_part parent="game.Workspace.Hospital" name="WallBack" size=40,10,1 cframe=0,5.5,19.5 material=Brick anchored=1 + + +Helpers (lights, spawn, cleanup) + + create_model parent="game.Workspace" name="LightingHelper" + + + game.ServerScriptService + ImportedCleanup + + + game.ServerScriptService.ImportedCleanup + [{"start":{"line":0,"character":0},"end":{"line":0,"character":0},"text":"local container = workspace:FindFirstChild('Hospital')\nif container then\n\tfor _, inst in ipairs(container:GetDescendants()) do\n\t\tif inst:IsA('Script') or inst:IsA('LocalScript') then\n\t\t\tinst:Destroy()\n\t\tend\n\tend\nend\n"}] +`, + `Manual fallback (guidance only) +- If the user asks for primitives or catalog searches fail, switch to run_command create_part + set_props and add an optional builder script. +- You may still use catalog assets when the user permits or provides an assetId.`, + String.raw`Examples (guidance only) - ["Create 'Structure' model under Workspace","Create 'Floor' Part with size 16x1x16 at y=0.5 Anchored","Create 'WallFront' Part 16x8x1 at z=-7.5 Anchored","Create 'Roof' Part 16x1x16 with slight tilt Anchored"] + ["Create Hospital model","Add floor and four walls","Add roof","Write HospitalBuilder","Summarize"] - - Model + game.Workspace - {"Name":"Structure"} - - - - Part - game.Workspace.Structure - {"Name":"Floor","Anchored":true,"Size":{"__t":"Vector3","x":16,"y":1,"z":16},"CFrame":{"__t":"CFrame","comps":[0,0.5,0, 1,0,0, 0,1,0, 0,0,1]},"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"WoodPlanks"}} - - - - Part - game.Workspace.Structure - {"Name":"WallFront","Anchored":true,"Size":{"__t":"Vector3","x":16,"y":8,"z":1},"CFrame":{"__t":"CFrame","comps":[0,4.5,-7.5, 1,0,0, 0,1,0, 0,0,1]},"Material":{"__t":"EnumItem","enum":"Enum.Material","name":"Brick"}} - - - - Part - game.Workspace.Structure - {"Name":"Roof","Anchored":true,"Size":{"__t":"Vector3","x":16,"y":1,"z":16},"CFrame":{"__t":"CFrame","comps":[0,8.6,0, 1,0,0, 0,0.5,-0.8660254, 0,0.8660254,0.5]},"Color":{"__t":"Color3","r":0.6,"g":0.2,"b":0.2}} -` + 1 + + + + create_model parent="game.Workspace" name="Hospital" + + + + create_part parent="game.Workspace.Hospital" name="Floor" size=40,1,40 cframe=0,0.5,0 material=Concrete anchored=1 +` ] const SYSTEM_PROMPT = PROMPT_SECTIONS.join('\n\n') @@ -712,6 +708,7 @@ type MapToolExtras = { recordPlanUpdate?: (update: { completedStep?: string; nextStep?: string; notes?: string }) => void userOptedOut?: boolean geometryTracker?: { sawCreate: boolean; sawParts: boolean } + subjectNouns?: string[] } type MapResult = { proposals: Proposal[]; missingContext?: string; contextResult?: any } @@ -728,6 +725,133 @@ function mapToolToProposals( const p = typeof a.path === 'string' ? a.path : undefined return p || (fallback || undefined) } + if (name === 'run_command') { + const cmdRaw = typeof (a as any).command === 'string' ? (a as any).command.trim() : '' + if (!cmdRaw) return { proposals, missingContext: 'Provide a command string.' } + const parseKV = (s: string): Record => { + const out: Record = {} + const re = /(\w+)=(("[^"]*")|('[^']*')|([^\s]+))/g + let m: RegExpExecArray | null + while ((m = re.exec(s))) { + const key = String(m[1]) + let val = String(m[2]) + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1) + } + out[key] = val + } + return out + } + const toBool = (v?: string) => { + if (!v) return undefined + const t = v.toLowerCase() + if (t === '1' || t === 'true' || t === 'yes' || t === 'on') return true + if (t === '0' || t === 'false' || t === 'no' || t === 'off') return false + return undefined + } + const parseVec3 = (v?: string) => { + if (!v) return undefined + const parts = v.replace(/[xX]/g, ',').split(/[,\s]+/).filter(Boolean) + if (parts.length < 3) return undefined + const nums = parts.slice(0, 3).map((p) => Number(p)) + if (nums.some((n) => !Number.isFinite(n))) return undefined + return { __t: 'Vector3', x: nums[0], y: nums[1], z: nums[2] } + } + const parseColor3 = (v?: string) => { + if (!v) return undefined + const parts = v.split(/[,\s]+/).filter(Boolean) + if (parts.length < 3) return undefined + const nums = parts.slice(0, 3).map((p) => Number(p)) + if (nums.some((n) => !Number.isFinite(n))) return undefined + return { __t: 'Color3', r: nums[0], g: nums[1], b: nums[2] } + } + const parseCFrame = (v?: string) => { + if (!v) return undefined + const parts = v.split(/[,\s]+/).filter(Boolean).map((p) => Number(p)) + if (parts.some((n) => !Number.isFinite(n))) return undefined + if (parts.length >= 12) return { __t: 'CFrame', comps: parts.slice(0, 12) } + if (parts.length >= 3) return { __t: 'CFrame', comps: [parts[0], parts[1], parts[2], 1,0,0, 0,1,0, 0,0,1] } + return undefined + } + const parseMaterial = (v?: string) => { + if (!v) return undefined + const name = v.trim() + if (!name) return undefined + return { __t: 'EnumItem', enum: 'Enum.Material', name } + } + const [verbRaw, ...rest] = cmdRaw.split(/\s+/) + const verb = (verbRaw || '').toLowerCase() + const argsLine = rest.join(' ') + const kv = parseKV(argsLine) + const addObjectOp = (op: ObjectOp) => { + proposals.push({ id: id('obj'), type: 'object_op', ops: [op], notes: `Parsed from run_command: ${verb}` }) + } + const addAssetOp = (insert?: { assetId: number; parentPath?: string }) => { + if (!insert) return + if (extras?.manualMode) return + proposals.push({ id: id('asset'), type: 'asset_op', insert, search: undefined, generate3d: undefined, meta: {} as any }) + } + if (verb === 'create_model') { + const parentPath = kv.parent || kv.parentPath || 'game.Workspace' + const name = kv.name || 'Model' + addObjectOp({ op: 'create_instance', className: 'Model', parentPath, props: { Name: name } }) + return { proposals } + } + if (verb === 'create_part') { + const parentPath = kv.parent || kv.parentPath || 'game.Workspace' + const name = kv.name || 'Part' + const size = parseVec3(kv.size) + const cf = parseCFrame(kv.cframe || kv.cf || kv.position) + const mat = parseMaterial(kv.material) + const anchored = toBool(kv.anchored) + const color = parseColor3(kv.color) + const props: Record = { Name: name } + if (size) props.Size = size + if (cf) props.CFrame = cf + if (mat) props.Material = mat + if (typeof anchored === 'boolean') props.Anchored = anchored + if (color) props.Color = color + addObjectOp({ op: 'create_instance', className: 'Part', parentPath, props }) + return { proposals } + } + if (verb === 'set_props' || verb === 'set_properties') { + const path = kv.path + if (!path) return { proposals, missingContext: 'set_props requires path=...' } + const props: Record = {} + if (kv.size) props.Size = parseVec3(kv.size) + if (kv.cframe || kv.cf || kv.position) props.CFrame = parseCFrame(kv.cframe || kv.cf || kv.position) + if (kv.material) props.Material = parseMaterial(kv.material) + if (kv.color) props.Color = parseColor3(kv.color) + const anchored = toBool(kv.anchored) + if (typeof anchored === 'boolean') props.Anchored = anchored + addObjectOp({ op: 'set_properties', path, props }) + return { proposals } + } + if (verb === 'rename') { + const path = kv.path + const newName = kv.newName || kv.name + if (!path || !newName) return { proposals, missingContext: 'rename requires path= and newName=' } + addObjectOp({ op: 'rename_instance', path, newName }) + return { proposals } + } + if (verb === 'delete') { + const path = kv.path + if (!path) return { proposals, missingContext: 'delete requires path=' } + addObjectOp({ op: 'delete_instance', path }) + return { proposals } + } + if (verb === 'insert_asset' || verb === 'insert') { + const assetId = Number(kv.assetId || kv.id) + if (!Number.isFinite(assetId) || assetId <= 0) return { proposals, missingContext: 'insert_asset requires assetId=' } + const parentPath = kv.parent || kv.parentPath + if (extras?.manualMode) { + return { proposals, missingContext: 'Manual mode active: asset commands are disabled. Use create_part/set_props or Luau.' } + } + addAssetOp({ assetId, parentPath }) + return { proposals } + } + return { proposals, missingContext: `Unknown command verb: ${verb}` } + } if (name === 'start_plan') { const steps = Array.isArray((a as any).steps) ? (a as any).steps.map(String) : [] if (!steps.length) { @@ -826,6 +950,25 @@ function mapToolToProposals( const childProps = (a as any).props as Record | undefined const ops: ObjectOp[] = [] + // Guard: keep names aligned to user nouns. Common pitfall: "House"/"SimpleHouse" for hospital tasks. + const wantsHospital = /\bhospital\b/i.test(msg) || (extras?.subjectNouns || []).some((s) => /\bhospital\b/i.test(s)) + if (wantsHospital && childClass === 'Model' && childProps && typeof childProps.Name === 'string') { + const nm = String(childProps.Name) + if (/^simple\s*house$/i.test(nm) || /^house$/i.test(nm) || /^structure$/i.test(nm)) { + (a as any).props = { ...childProps, Name: 'Hospital' } + } + } + let normalizedParentPath = parentPath + if (wantsHospital && typeof normalizedParentPath === 'string') { + normalizedParentPath = normalizedParentPath + .replace(/(game\.)?Workspace\.SimpleHouse\b/i, 'game.Workspace.Hospital') + .replace(/(game\.)?Workspace\.House\b/i, 'game.Workspace.Hospital') + .replace(/(game\.)?Workspace\.Structure\b/i, 'game.Workspace.Hospital') + .replace(/^Workspace\.SimpleHouse\b/i, 'Workspace.Hospital') + .replace(/^Workspace\.House\b/i, 'Workspace.Hospital') + .replace(/^Workspace\.Structure\b/i, 'Workspace.Hospital') + } + // Build known scene paths, with and without 'game.' prefix for services const nodes = Array.isArray(input.context.scene?.nodes) ? input.context.scene!.nodes! : [] const known = new Set() @@ -838,35 +981,41 @@ function mapToolToProposals( if (SERVICE_PREFIXES.includes(head)) known.add(`game.${p}`) } - const looksLikeWorkspace = /^game\.Workspace(?:\.|$)/i.test(parentPath) || /^Workspace(?:\.|$)/.test(parentPath) + const looksLikeWorkspace = /^game\.Workspace(?:\.|$)/i.test(normalizedParentPath) || /^Workspace(?:\.|$)/.test(normalizedParentPath) if (looksLikeWorkspace) { - // Walk up and create missing ancestors as Models under Workspace - const chain: { parent: string; name: string }[] = [] - let cur = parentPath - let guard = 0 - while (cur && !known.has(cur) && guard++ < 10) { - const noGame = cur.replace(/^game\./, '') - if (known.has(noGame)) break - const split = splitInstancePath(cur) - const inferredParent = split.parentPath || 'game.Workspace' - const inferredName = split.name || 'Model' - if (/^game\.Workspace$/i.test(inferredParent) || /^Workspace$/i.test(inferredParent)) { - const normalizedParent = /^Workspace$/i.test(inferredParent) ? 'game.Workspace' : inferredParent - chain.push({ parent: normalizedParent, name: inferredName }) + // Skip ancestor creation when targeting the root Workspace directly + if (!/^game\.Workspace$/i.test(normalizedParentPath) && !/^Workspace$/i.test(normalizedParentPath)) { + // Walk up and create missing ancestors (beyond Workspace) as Models under Workspace + const chain: { parent: string; name: string }[] = [] + let cur = normalizedParentPath + let guard = 0 + while (cur && !known.has(cur) && guard++ < 10) { + const noGame = cur.replace(/^game\./, '') + if (known.has(noGame)) break + const split = splitInstancePath(cur) + const inferredParent = split.parentPath || 'game.Workspace' + const inferredName = split.name || 'Model' + // Avoid generating bogus entries for the Workspace or 'game' segment + const isRootParent = /^game\.Workspace$/i.test(inferredParent) || /^Workspace$/i.test(inferredParent) + const isRootName = /^Workspace$/i.test(inferredName) || /^game$/i.test(inferredName) + if (isRootParent && !isRootName) { + const normalizedParent = /^Workspace$/i.test(inferredParent) ? 'game.Workspace' : inferredParent + chain.push({ parent: normalizedParent, name: inferredName }) + } + if (!split.parentPath || /^game\.Workspace$/i.test(split.parentPath) || /^Workspace$/i.test(split.parentPath)) break + cur = split.parentPath + } + for (let i = chain.length - 1; i >= 0; i--) { + const seg = chain[i] + ops.push({ op: 'create_instance', className: 'Model', parentPath: seg.parent, props: { Name: seg.name } }) + const createdPath = buildInstancePath(seg.parent.replace(/^game\./, ''), seg.name).replace(/^Workspace\./, 'Workspace.') + known.add(createdPath) + known.add(`game.${createdPath}`) } - if (!split.parentPath || /^game\.Workspace$/i.test(split.parentPath) || /^Workspace$/i.test(split.parentPath)) break - cur = split.parentPath - } - for (let i = chain.length - 1; i >= 0; i--) { - const seg = chain[i] - ops.push({ op: 'create_instance', className: 'Model', parentPath: seg.parent, props: { Name: seg.name } }) - const createdPath = buildInstancePath(seg.parent.replace(/^game\./, ''), seg.name).replace(/^Workspace\./, 'Workspace.') - known.add(createdPath) - known.add(`game.${createdPath}`) } } - ops.push({ op: 'create_instance', className: childClass, parentPath, props: childProps }) + ops.push({ op: 'create_instance', className: childClass, parentPath: normalizedParentPath, props: (a as any).props as any }) proposals.push({ id: id('obj'), type: 'object_op', ops, notes: 'Parsed from create_instance' }) if (extras?.geometryTracker) { extras.geometryTracker.sawCreate = true @@ -980,11 +1129,37 @@ function mapToolToProposals( targetPath = buildInstancePath(parentPath, scriptName) } + const normalizePath = (value?: string) => normalizeInstancePath(value) || undefined + const pathsEqual = (left?: string, right?: string): boolean => { + const normLeft = normalizePath(left) + const normRight = normalizePath(right) + if (!normLeft || !normRight) return false + if (normLeft === normRight) return true + const withGameLeft = normLeft.startsWith('game.') ? normLeft : `game.${normLeft}` + const withGameRight = normRight.startsWith('game.') ? normRight : `game.${normRight}` + if (withGameLeft === withGameRight) return true + const withoutGameLeft = normLeft.replace(/^game\./, '') + const withoutGameRight = normRight.replace(/^game\./, '') + return withoutGameLeft === withoutGameRight + } + + const activeScript = input.context.activeScript const knownSource = targetPath ? extras?.getScriptSource?.(targetPath) : undefined - let created = false - let text = typeof knownSource === 'string' ? knownSource : '' + let text: string | undefined = typeof knownSource === 'string' ? knownSource : undefined - if (targetPath && typeof knownSource !== 'string') { + if (text === undefined && activeScript && pathsEqual(activeScript.path, targetPath)) { + text = typeof activeScript.text === 'string' ? activeScript.text : '' + } + + const sceneNodes = Array.isArray(input.context.scene?.nodes) ? input.context.scene!.nodes! : [] + const scriptExistsInScene = !!targetPath && sceneNodes.some((node) => pathsEqual(node?.path, targetPath)) + + if (text === undefined && scriptExistsInScene) { + return { proposals, missingContext: `Need script Source for ${targetPath}. Open the script or call .` } + } + + let created = false + if (targetPath && text === undefined) { created = true text = '' const op: ObjectOp = { @@ -999,10 +1174,13 @@ function mapToolToProposals( notes: 'Ensure script exists before editing.', ops: [op], }) + } + + if (targetPath && text !== undefined) { extras?.recordScriptSource?.(targetPath, text) } - return { proposals, contextResult: { path: targetPath, text, created } } + return { proposals, contextResult: { path: targetPath, text: text ?? '', created } } } if (name === 'complete') { const summary = typeof (a as any).summary === 'string' ? (a as any).summary : undefined @@ -1100,17 +1278,14 @@ const SCRIPT_OPT_IN_PATTERNS = [ /\bprovide\s+(?:the\s+)?script\b/i, ] -const GEOMETRY_INTENT_PATTERNS = [ - /\b(build|create|make|spawn|place|add|put|drop)\b[^.!?]{0,120}\b(house|building|structure|scene|environment|world|level|map|terrain|tower|bridge|park|city|village|room|base|farm|garden|landscape)\b/i, - /\bpopulate\b[^.!?]{0,120}\b(scene|world|environment)\b/i, - /\bdecorate\b[^.!?]{0,120}\b(scene|area|room)\b/i, -] +// Removed implicit geometry-intent opt-out: natural phrases like "build a hospital" should not disable scripting +const GEOMETRY_INTENT_PATTERNS: RegExp[] = [] function detectScriptOptPreference(text: string): boolean | null { if (!text) return null if (SCRIPT_OPT_OUT_PATTERNS.some((pat) => pat.test(text))) return true if (SCRIPT_OPT_IN_PATTERNS.some((pat) => pat.test(text))) return false - if (GEOMETRY_INTENT_PATTERNS.some((pat) => pat.test(text))) return true + // Do not infer opt-out from generic build verbs; prefer explicit user opt-in/opt-out return null } @@ -1158,6 +1333,8 @@ function proposalTouchesGeometry(proposal: Proposal): boolean { return false } +// + export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; taskState: TaskState; tokenTotals: { in: number; out: number } }> { const rawMessage = input.message.trim() const { cleaned, attachments } = await extractMentions(rawMessage) @@ -1218,6 +1395,20 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } const scriptSources: Record = { ...(taskState.scriptSources || {}) } + // Infer stable subject nouns (e.g., "hospital") from prior user messages or plan steps + const subjectNouns: string[] = (() => { + const nouns = new Set() + try { + const hist = Array.isArray(taskState.history) ? taskState.history : [] + const histText = hist.map((h) => (h?.content || '')).join(' \n ') + if (/\bhospital\b/i.test(histText)) nouns.add('hospital') + } catch {} + try { + const steps = Array.isArray(taskState.plan?.steps) ? taskState.plan!.steps : [] + if (steps.some((s) => /\bHospital\b/i.test(String(s)))) nouns.add('hospital') + } catch {} + return Array.from(nouns) + })() const normalizeScriptPath = (path?: string) => (path || '').trim() const getScriptSource = (path: string): string | undefined => { const key = normalizeScriptPath(path) @@ -1237,7 +1428,24 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } const recordPlanStart = (steps: string[]) => { - const normalized = steps.map((s) => (typeof s === 'string' ? s.trim() : '')).filter((s) => s.length > 0) + const raw = steps.map((s) => (typeof s === 'string' ? s.trim() : '')).filter((s) => s.length > 0) + // Sanitize step names to avoid subject drift (e.g., "SimpleHouse" instead of requested "Hospital") + const normalized = raw.map((s) => { + let out = s + if ((subjectNouns || []).some((n) => /\bhospital\b/i.test(n))) { + out = out + .replace(/\bSimpleHouse\b/g, 'Hospital') + .replace(/\bHouse\b/g, 'Hospital') + .replace(/\bStructure\b/g, 'Hospital') + } + return out + }) + const current = Array.isArray(taskState.plan?.steps) ? taskState.plan!.steps : [] + const changed = JSON.stringify(current) !== JSON.stringify(normalized) + if (!changed) { + pushChunk(streamKey, 'plan.keep (no change)') + return + } updateState((state) => { state.plan = { steps: normalized, completed: [], currentIndex: 0 } }) @@ -1245,6 +1453,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } const recordPlanUpdate = (update: { completedStep?: string; nextStep?: string; notes?: string }) => { + let changed = false updateState((state) => { if (!state.plan) { state.plan = { steps: [], completed: [], currentIndex: 0 } @@ -1255,19 +1464,22 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; if (idx >= 0 && !plan.completed.includes(update.completedStep)) { plan.completed.push(update.completedStep) plan.currentIndex = Math.min(idx + 1, plan.steps.length - 1) + changed = true } } if (update.nextStep) { const idx = plan.steps.findIndex((step) => step === update.nextStep) - if (idx >= 0) { + if (idx >= 0 && plan.currentIndex !== idx) { plan.currentIndex = idx + changed = true } } - if (typeof update.notes === 'string' && update.notes.trim().length > 0) { + if (typeof update.notes === 'string' && update.notes.trim().length > 0 && plan.notes !== update.notes.trim()) { plan.notes = update.notes.trim() + changed = true } }) - pushChunk(streamKey, 'plan.update ' + JSON.stringify(update)) + pushChunk(streamKey, changed ? ('plan.update ' + JSON.stringify(update)) : 'plan.keep (no material change)') } let userOptedOut = !!taskState.policy?.userOptedOut @@ -1324,6 +1536,8 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; : '' const providerFirstMessage = attachments.length ? `${msg}\n\n[ATTACHMENTS]\n${attachmentSummary}` : msg + const currentPolicy = ensureScriptPolicy(taskState) + const contextRequestLimit = 1 let contextRequestsThisCall = 0 let requestAdditionalContext: (reason: string) => boolean = () => false @@ -1347,7 +1561,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; let messages: { role: 'user' | 'assistant' | 'system'; content: string }[] | null = null let assetFallbackWarningSent = false - const catalogSearchAvailable = (process.env.CATALOG_DISABLE_SEARCH || '0') !== '1' + let catalogSearchAvailable = (process.env.CATALOG_DISABLE_SEARCH || '0') !== '1' let scriptWarnings = 0 const finalize = (list: Proposal[]): { proposals: Proposal[]; taskState: TaskState; tokenTotals: { in: number; out: number } } => { @@ -1595,7 +1809,9 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; const planReady = Array.isArray(taskState.plan?.steps) && (taskState.plan?.steps.length || 0) > 0 const askMode = (input.mode || 'agent') === 'ask' - const requirePlan = (process.env.VECTOR_REQUIRE_PLAN || '0') === '1' + const envRequirePlan = process.env.VECTOR_REQUIRE_PLAN + // Default OFF: only require a plan when explicitly enabled via env. + const requirePlan = (typeof envRequirePlan === 'string' && envRequirePlan.trim().length > 0) ? (envRequirePlan === '1') : false const isContextOrNonActionTool = ( toolName === 'start_plan' || toolName === 'update_plan' || @@ -1613,6 +1829,11 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; toolName === 'search_files' ) const isActionTool = !isContextOrNonActionTool + + // Allow duplicate start_plan: if steps are unchanged, no-op; otherwise replace (handled by recordPlanStart) + if (toolName === 'start_plan' && planReady) { + pushChunk(streamKey, 'plan.duplicate allowed') + } if (!planReady && isActionTool && !askMode && requirePlan) { const errMsg = 'PLAN_REQUIRED Call with a step-by-step outline before taking actions.' pushChunk(streamKey, `error.validation ${String(name)} plan_required`) @@ -1623,6 +1844,15 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; continue } + // Record asset usage counters + if (toolName === 'search_assets') { + updateState((state) => { const p = ensureScriptPolicy(state); p.assetSearches = (p.assetSearches || 0) + 1 }) + } else if (toolName === 'insert_asset') { + updateState((state) => { const p = ensureScriptPolicy(state); p.assetInserts = (p.assetInserts || 0) + 1 }) + } + + // No asset-first enforcement; allow either assets or direct geometry per user intent + if ((name === 'show_diff' || name === 'apply_edit') && !a.path && input.context.activeScript?.path) { a = { ...a, path: input.context.activeScript.path } } @@ -1780,6 +2010,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; recordPlanUpdate, userOptedOut, geometryTracker, + subjectNouns, }) if (mapped.contextResult !== undefined) { @@ -1809,20 +2040,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; name === 'attempt_completion' || (name === 'message' && typeof (a as any).phase === 'string' && (a as any).phase.toLowerCase() === 'final') - const scriptRequired = geometryWorkObserved && !scriptWorkObserved && !userOptedOut - if (isFinalPhase && scriptRequired) { - scriptWarnings += 1 - consecutiveValidationErrors += 1 - const warn = - 'SCRIPT_REQUIRED Default Script Policy: add Luau in a Script/ModuleScript (open_or_create_script → show_diff) that rebuilds the created Instances before completing. Say "geometry only" if you really need to skip.' - pushChunk(streamKey, 'error.validation script_required') - console.warn(`[orch] validation.script_missing tool=${String(name)}`) - convo.push({ role: 'assistant', content: toolXml }) - convo.push({ role: 'user', content: warn }) - appendHistory('system', warn) - if (consecutiveValidationErrors > validationRetryLimit || scriptWarnings > validationRetryLimit) break - continue - } + // No script-required gate: allow completion when user decides if (mapped.proposals.length) { updateState((state) => { @@ -1839,6 +2057,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } } }) + // counters updated pushChunk(streamKey, `proposals.mapped ${String(name)} count=${mapped.proposals.length}`) console.log(`[orch] proposals.mapped tool=${String(name)} count=${mapped.proposals.length}`) // Stream assistant text for UI transcript @@ -1904,8 +2123,8 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; } if (!fallbacksDisabled && !assetFallbackWarningSent) { - const warn = 'CATALOG_UNAVAILABLE Asset catalog lookup failed. Create the requested objects manually using create_instance or Luau edits.' - pushChunk(streamKey, 'fallback.asset manual_required') + const warn = 'CATALOG_UNAVAILABLE Asset catalog lookup failed. Consider manual geometry or alternative assets.' + pushChunk(streamKey, 'fallback.asset manual_suggest') console.log('[orch] fallback.asset manual_required; instructing provider to create manually') appendHistory('assistant', 'fallback: asset search disabled (request manual creation)') convo.push({ role: 'user', content: warn }) @@ -1962,7 +2181,7 @@ export async function runLLM(input: ChatInput): Promise<{ proposals: Proposal[]; ]) } - if (!fallbacksDisabled) { + if (fallbacksDisabled) { const errMsg = 'ASSET_FALLBACK_DISABLED Asset catalog fallback is disabled. Create the requested objects manually using create_instance or Luau edits.' pushChunk(streamKey, 'fallback.asset disabled') console.warn('[orch] fallback.asset disabled; no proposals after provider warning') diff --git a/vector/apps/web/lib/orchestrator/prompts/examples.ts b/vector/apps/web/lib/orchestrator/prompts/examples.ts index f62c3ef..151f5ed 100644 --- a/vector/apps/web/lib/orchestrator/prompts/examples.ts +++ b/vector/apps/web/lib/orchestrator/prompts/examples.ts @@ -2,18 +2,10 @@ // Keep these short, deterministic, and copy-ready for the model. export const PLANNER_GUIDE = ` -Planning -- Translate the user's goal into specific placements and code changes. -- When planning, produce a DETAILED, TOOL-ORIENTED step list via . - - 8–15 concise steps typical for multi‑object builds; more if needed. - - One tool per step and name the exact action: - - The tool name (create_instance, set_properties, search_assets, insert_asset, open_or_create_script, show_diff, ...) - - The exact target (className, path/parentPath, and Name) - - The intention (e.g., size/position, purpose like "outer fence", or code outcome) - - Examples of good step text: "Create Model 'MilitaryBase' under game.Workspace", "Search assets: query='watch tower' tags=['model'] limit=6", "Insert asset 123456789 under game.Workspace.MilitaryBase", "Set CFrame/Anchored for 'Gate' at (0,0,50)", "Open or create Script 'BaseBuilder' in ServerScriptService", "Show diff to insert idempotent Luau that rebuilds placed structures". -- Prefer assets first: plan searches and inserts before manual geometry. Use create_instance for primitives or when search/insert is unavailable. -- Use as you progress (mark completed, set next, add notes). Keep steps small and verifiable. -- Always add a Luau step (unless user opted out) to rebuild what was created. +Planning (guidance only) +- Use to outline multi-step work and to mark progress or adjust. Keep one active plan; reuse it. +- Steps should be concrete and tool-oriented (e.g., run_command to create parts, open_or_create_script + show_diff to write Luau). +- Keep plans short and actionable; do not introduce unrelated content or rename the user’s subjects. `; export const COMPLEXITY_DECISION_GUIDE = ` @@ -30,40 +22,18 @@ When to plan `; export const TOOL_REFERENCE = ` -Tool reference (purpose and tips) -- start_plan: Begin an ordered list of steps. Use for multi-step work. -- update_plan: Mark a step done, set next step, or add notes. -- get_active_script: Return the currently open script (path, text) if any. -- list_selection: Return the current Studio selection (array of paths/classes). -- list_open_documents: Return open documents; useful to infer likely targets. -- open_or_create_script: Ensure a Script/LocalScript/ModuleScript exists; returns {path,text,created}. -- list_children: Inspect scene tree under a path. Add classWhitelist to filter. -- get_properties: Read properties/attributes for a path; set includeAllAttributes for attributes. -- list_code_definition_names: Enumerate known code symbol names for navigation. -- search_files: Grep-like substring search across files (case-insensitive by default). -- show_diff: Propose edits to a file. Prefer first before apply_edit. Supports […] for multi-file. -- apply_edit: Apply edits directly (use sparingly; prefer show_diff previews first). -- create_instance: Create a Roblox instance at parentPath with optional props. -- set_properties: Update properties on an existing instance. -- rename_instance: Rename an instance at a path. -- delete_instance: Delete an instance. -- search_assets: Search catalog for assets (limit ≤ 6 unless asked otherwise). -- insert_asset: Insert an assetId into the scene (defaults to game.Workspace). -- generate_asset_3d: Request a generated asset; include tags/style/budget if helpful. -- complete: Mark the task complete with a succinct summary. -- message: Stream a short text update with phase=start|update|final. -- final_message: Send the final summary (Ask-friendly) and end the turn. -- attempt_completion: Alias for completion; include result and optional confidence. +Tools (concise) +- run_command (default for actions): create_model, create_part, set_props, rename, delete, insert_asset. +- list_children: read scene tree (parentPath, depth, classWhitelist). +- start_plan / update_plan: outline and track steps. +- open_or_create_script / show_diff: write idempotent Luau when needed. +- complete / final_message / message: summaries and updates. `; export const EXAMPLES_POLICY = ` Examples policy -- Examples shown in this prompt are illustrative guidance only. They are not commands. -- Choose an example pattern only if it matches the current user goal; otherwise proceed without one. -- Never introduce unrelated names or content from examples (e.g., do not create \"Farm\" or \"FarmBuilder\" when the user asked for a house). -- Keep examples as text-only guidance. Always prioritize the user's request and the current scene/context. - - Strictly follow the user's requested subject and nouns. Do not pivot to different subjects or example names. If the user asks for a "hospital", do not create a "house", "base", or any unrelated structure. - - When continuing a plan, prefer steps that explicitly progress the user's requested build; avoid switching to generic examples. +- Examples are for guidance only. Do not treat them as commands. +- Always follow the user’s request and current context. Keep subject names aligned with the user’s nouns. `; export const WORKFLOW_EXAMPLES = ` @@ -124,22 +94,12 @@ Asset search and insert (planned) export const ROLE_SCOPE_GUIDE = ` Role and approach -- Be a precise, safety-first Roblox Studio copilot. -- Favor minimal, reviewable steps with previews (show_diff/object ops). -- Use the user's existing names/styles when extending code or scenes. -- Prefer reading context (selection, open docs, search) before guessing paths. -- Avoid destructive changes; never delete or overwrite large files blindly. -- Summarize outcomes with complete/attempt_completion or final_message when done. +- Be precise. Keep steps minimal and safe. Use the user’s names. Prefer reading context before acting. Summarize when done. `; export const QUALITY_CHECK_GUIDE = ` Quality check -- Derive a checklist of deliverables straight from the prompt (e.g., floor, four walls, roof). -- Track completion of each checklist item as you work; optionally stream progress updates. -- Only call once every checklist item is satisfied with visible, anchored geometry or verified code. -- Placeholder Models or unattached Parts never count toward completion. -- Ensure reproducible Luau exists: update a Script/ModuleScript (or repo .lua/.luau file) so the build can be rebuilt from code before completing. -- Use open_or_create_script(path,parentPath?,name?) to guarantee a script container exists before diffing or editing its Source. +- Build from the user’s checklist. Only complete after visible anchored Parts or idempotent Luau exist. Placeholder Models don’t count. `; export const EXAMPLE_HOUSE_SMALL = ` diff --git a/vector/apps/web/lib/orchestrator/sceneGraph.ts b/vector/apps/web/lib/orchestrator/sceneGraph.ts index e18d55b..90b66bc 100644 --- a/vector/apps/web/lib/orchestrator/sceneGraph.ts +++ b/vector/apps/web/lib/orchestrator/sceneGraph.ts @@ -45,10 +45,32 @@ function ensureScene(state: TaskState): SceneGraph { return state.scene } +const SERVICE_HEADS = new Set([ + 'Workspace', + 'ReplicatedStorage', + 'ServerStorage', + 'StarterGui', + 'StarterPack', + 'StarterPlayer', + 'Lighting', + 'Teams', + 'SoundService', + 'TextService', + 'CollectionService', +]) + export function normalizeInstancePath(path?: string): string | undefined { if (typeof path !== 'string') return undefined - const trimmed = path.trim() - return trimmed.length > 0 ? trimmed : undefined + let trimmed = path.trim() + if (!trimmed) return undefined + // Canonicalize service heads to include 'game.' prefix for consistency + // Examples: 'Workspace', 'Workspace.Hospital' -> 'game.Workspace', 'game.Workspace.Hospital' + const startsWithGame = trimmed.startsWith('game.') + const head = trimmed.split('.')[0] + if (!startsWithGame && SERVICE_HEADS.has(head)) { + trimmed = `game.${trimmed}` + } + return trimmed } type SnapshotNode = { path: string; className: string; name: string; parentPath?: string; props?: Record } diff --git a/vector/apps/web/lib/orchestrator/taskState.ts b/vector/apps/web/lib/orchestrator/taskState.ts index bd84e0a..8499ae5 100644 --- a/vector/apps/web/lib/orchestrator/taskState.ts +++ b/vector/apps/web/lib/orchestrator/taskState.ts @@ -39,6 +39,9 @@ export type ScriptPolicyState = { userOptedOut?: boolean lastOptOutAt?: number nudgedAt?: number + assetSearches?: number + assetInserts?: number + manualMode?: boolean } export interface TaskState { @@ -108,10 +111,13 @@ function ensureCheckpointFields(state: TaskState): TaskState { state.plan = { steps: [], completed: [], currentIndex: 0 } } if (!state.policy || typeof state.policy !== 'object') { - state.policy = { geometryOps: 0, luauEdits: 0 } + state.policy = { geometryOps: 0, luauEdits: 0, assetSearches: 0, assetInserts: 0, manualMode: false } } else { state.policy.geometryOps = Math.max(0, Number(state.policy.geometryOps) || 0) state.policy.luauEdits = Math.max(0, Number(state.policy.luauEdits) || 0) + state.policy.assetSearches = Math.max(0, Number(state.policy.assetSearches) || 0) + state.policy.assetInserts = Math.max(0, Number(state.policy.assetInserts) || 0) + state.policy.manualMode = !!state.policy.manualMode } return state } @@ -129,7 +135,7 @@ function defaultState(taskId: string): TaskState { codeDefinitions: [], scene: { nodes: {} }, plan: { steps: [], completed: [], currentIndex: 0 }, - policy: { geometryOps: 0, luauEdits: 0 }, + policy: { geometryOps: 0, luauEdits: 0, manualMode: false, assetSearches: 0, assetInserts: 0 }, checkpoints: { count: 0 }, updatedAt: now, } diff --git a/vector/apps/web/lib/tools/schemas.ts b/vector/apps/web/lib/tools/schemas.ts index 07a14e4..57b2ebb 100644 --- a/vector/apps/web/lib/tools/schemas.ts +++ b/vector/apps/web/lib/tools/schemas.ts @@ -54,6 +54,9 @@ const ParentEither = z .transform((v) => ('parentPath' in v ? v : { parentPath: (v as any).parent })) export const Tools = { + run_command: z.object({ + command: z.string().min(1), + }), get_active_script: z.object({}), list_selection: z.object({}), list_open_documents: z.object({ maxCount: z.number().min(1).max(100).optional() }), diff --git a/vector/apps/web/package.json b/vector/apps/web/package.json index 2552038..1616dc7 100644 --- a/vector/apps/web/package.json +++ b/vector/apps/web/package.json @@ -11,7 +11,8 @@ "test:orchestrator": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node lib/orchestrator/index.test.ts", "test:providers": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-providers.ts", "test:select": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-select.ts", - "test:catalog": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-catalog.ts" + "test:catalog": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-catalog.ts", + "test:workflow:hospital": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\",\\\"moduleResolution\\\":\\\"node\\\"}\" ts-node scripts/test-workflow-hospital.ts" }, "dependencies": { "adm-zip": "^0.5.12", diff --git a/vector/apps/web/scripts/test-workflow-hospital.ts b/vector/apps/web/scripts/test-workflow-hospital.ts new file mode 100644 index 0000000..564f79f --- /dev/null +++ b/vector/apps/web/scripts/test-workflow-hospital.ts @@ -0,0 +1,61 @@ +/* Workflow smoke test: plan-first + asset-first for hospital */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +function loadDotEnvLocal() { + try { + const p = resolve(__dirname, '..', '.env.local') + const text = readFileSync(p, 'utf8') + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq <= 0) continue + const key = trimmed.slice(0, eq).trim() + const val = trimmed.slice(eq + 1).trim() + if (!(key in process.env)) process.env[key] = val + } + } catch {} +} + +async function main() { + loadDotEnvLocal() + const { runLLM } = await import('../lib/orchestrator') + const workflowId = `wf_test_${Date.now().toString(36)}` + console.log(`[workflow.test] using workflowId=${workflowId}`) + + // Step 1: request a plan only + const msg1 = 'Build a hospital into the scene. Return exactly one and then stop.' + const r1 = await runLLM({ + projectId: 'local', + workflowId, + message: msg1, + context: { activeScript: null, selection: [], scene: {} }, + mode: 'agent', + maxTurns: 1, + modelOverride: process.env.BEDROCK_MODEL || process.env.AWS_BEDROCK_MODEL || 'qwen.qwen3-coder-30b-a3b-v1:0', + } as any) + const steps = r1.taskState?.plan?.steps || [] + console.log(`[workflow.test] plan.steps=${steps.length}`) + for (const s of steps) console.log(' -', s) + + // Step 2: approve and expect asset-first search + const r2 = await runLLM({ + projectId: 'local', + workflowId, + message: 'proceed', + context: { activeScript: null, selection: [], scene: {} }, + mode: 'agent', + maxTurns: 1, + modelOverride: process.env.BEDROCK_MODEL || process.env.AWS_BEDROCK_MODEL || 'qwen.qwen3-coder-30b-a3b-v1:0', + } as any) + const types = (r2.proposals || []).map((p: any) => p?.type) + const gotSearch = (r2.proposals || []).some((p: any) => p?.type === 'asset_op' && p?.search) + console.log(`[workflow.test] second.turn proposals=${types.join(',') || 'none'} asset_search=${gotSearch}`) + if (!gotSearch) { + console.warn('[workflow.test] WARN: did not see asset search on second turn — check provider output and policy') + } +} + +main().catch((e) => { console.error(e); process.exit(1) }) + diff --git a/vector/plugin/Vector.rbxmx b/vector/plugin/Vector.rbxmx index 10e4640..4990b99 100644 --- a/vector/plugin/Vector.rbxmx +++ b/vector/plugin/Vector.rbxmx @@ -40,6 +40,18 @@ _G.__VECTOR_PROGRESS = _G.__VECTOR_PROGRESS or 0 _G.__VECTOR_RUNS = _G.__VECTOR_RUNS or {} _G.__VECTOR_LAST_WORKFLOW_ID = _G.__VECTOR_LAST_WORKFLOW_ID or nil +local FAILED_ASSET_IDS = _G.__VECTOR_FAILED_ASSET_IDS +if type(FAILED_ASSET_IDS) ~= "table" then + FAILED_ASSET_IDS = {} + _G.__VECTOR_FAILED_ASSET_IDS = FAILED_ASSET_IDS +end + +local GAME_CREATOR_ID = tonumber(game.CreatorId) or 0 +local GAME_CREATOR_TYPE = tostring(game.CreatorType) + +-- TEMP: Restrict to Roblox-owned assets only +local ROBLOX_ONLY = false + -- UI state (shared across UI and actions) local CURRENT_MODE = "agent" -- or "ask" @@ -600,6 +612,15 @@ local function renderUnifiedDiff(container, oldText, newText) container.CanvasSize = UDim2.new(0, 0, 0, container.UIListLayout.AbsoluteContentSize.Y + 8) end +local function openScriptByPath(path) + local inst = resolveByFullName(path) + if inst then + pcall(function() + ScriptEditorService:OpenScript(inst) + end) + end +end + local function renderConflictDetails(container, files) container:ClearAllChildren() local layout = Instance.new("UIListLayout") @@ -710,15 +731,6 @@ local function applyEditProposal(proposal) return true, nil, results end -local function openScriptByPath(path) - local inst = resolveByFullName(path) - if inst then - pcall(function() - ScriptEditorService:OpenScript(inst) - end) - end -end - local function getBackendBaseUrl() -- Prefer plugin setting if present, fallback to local dev server local val @@ -780,7 +792,29 @@ local function fetchAssets(query, limit, tags) if not ok then return false, "Invalid JSON" end - return true, json.results or {} + local raw = json.results or {} + local filtered = {} + for _, entry in ipairs(raw) do + local assetId = assetIdKey(entry and (entry.id or entry.assetId or entry.AssetId)) + if assetId and assetId > 0 and assetId < 100000000000 then + local typeName = nil + if typeof(entry.type) == "string" then + typeName = string.lower(entry.type) + elseif typeof(entry.AssetType) == "string" then + typeName = string.lower(entry.AssetType) + elseif typeof(entry.assetType) == "string" then + typeName = string.lower(entry.assetType) + elseif typeof(entry.assetType) == "number" then + typeName = tostring(entry.assetType) + end + local isModelLike = typeName == nil or string.find(typeName, "model", 1, true) ~= nil or typeName == "8" + if isModelLike then + entry.id = assetId + table.insert(filtered, entry) + end + end + end + return true, filtered end local function checkpointsBaseUrl() @@ -823,6 +857,123 @@ local function restoreCheckpointRequest(checkpointId, mode) return true, parsed.checkpoint end +local function assetIdKey(assetId) + if typeof(assetId) == "number" then + return assetId + end + local asNumber = tonumber(assetId) + if typeof(asNumber) == "number" and asNumber == asNumber then + return asNumber + end + return nil +end + +local function recordAssetFailure(assetId, message) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = message or true +end + +local function clearAssetFailure(assetId) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = nil +end + +local function isRobloxCreatorName(name) + if typeof(name) ~= "string" then return false end + local lower = string.lower(name) + return lower == "roblox" or lower == "roblox team" or lower == "roblox official" or string.sub(lower, 1, 7) == "roblox " +end + +local function isRobloxEntry(entry) + local creatorName = (typeof(entry.creator) == "string" and entry.creator) + or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then creatorType = trimmed end + end + if isRobloxCreatorName(creatorName) then return true end + if creatorId == 1 then return true end + if creatorType == "roblox" then return true end + return false +end + +local function computeCatalogScore(entry, index) + local score = (index or 0) * 10 + local creatorName = (typeof(entry.creator) == "string" and entry.creator) or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then + creatorType = trimmed + end + end + local isVerified = entry.isVerified == true or entry.IsVerified == true or entry.creatorHasVerifiedBadge == true + + local assetId = assetIdKey(entry.id or entry.AssetId) + if assetId and FAILED_ASSET_IDS[assetId] then + score += 100000 + end + + if isRobloxCreatorName(creatorName) or (creatorType == "roblox") or (creatorId == 1) then + score = score - 8000 + end + + if creatorId and creatorId == GAME_CREATOR_ID then + score = score - 6000 + elseif GAME_CREATOR_TYPE == tostring(Enum.CreatorType.Group) and creatorType == "group" and creatorId == GAME_CREATOR_ID then + score = score - 4000 + end + + if isVerified then + score = score - 400 + end + + return score +end + +local function sortCatalogResults(results) + local scored = {} + -- Optional strict Roblox-only filter + if ROBLOX_ONLY then + local filtered = {} + for _, e in ipairs(results) do + if isRobloxEntry(e) then table.insert(filtered, e) end + end + results = filtered + end + for index, entry in ipairs(results) do + table.insert(scored, { entry = entry, score = computeCatalogScore(entry, index) }) + end + table.sort(scored, function(a, b) + if a.score == b.score then return (a.entry.id or a.entry.AssetId or 0) < (b.entry.id or b.entry.AssetId or 0) end + return a.score < b.score + end) + local ordered = {} + for _, info in ipairs(scored) do + table.insert(ordered, info.entry) + end + return ordered +end + local function insertAsset(assetId, parentPath) local parent = workspace if parentPath then @@ -831,21 +982,170 @@ local function insertAsset(assetId, parentPath) parent = resolved end end + local key = assetIdKey(assetId) + if not key then + return false, "invalid_asset_id" + end + if FAILED_ASSET_IDS[key] then + return false, FAILED_ASSET_IDS[key] + end local ok, modelOrErr = pcall(function() local container = InsertService:LoadAsset(assetId) if not container then error("LoadAsset returned nil") end local model = container:FindFirstChildOfClass("Model") or container model.Parent = parent - -- If the container is different from the inserted model, clean it up if container ~= model then pcall(function() container:Destroy() end) end return model end) if not ok then + recordAssetFailure(assetId, tostring(modelOrErr)) return false, modelOrErr end + clearAssetFailure(assetId) return true, modelOrErr end +local function insertAssetFromFreeModels(query, parentPath, relativePosition, opts) + if typeof(query) ~= "string" or #query == 0 then + return false, "invalid_query" + end + local parent = workspace + if parentPath then + local resolved = resolveByFullName(parentPath) + if resolved and resolved:IsA("Instance") then + parent = resolved + end + end + local cam = workspace.CurrentCamera + local origin = cam and cam.CFrame and cam.CFrame.Position or Vector3.new() + local offset + if typeof(relativePosition) == "Vector3" then + offset = relativePosition + elseif cam and cam.CFrame then + offset = cam.CFrame.LookVector * 16 + else + offset = Vector3.new() + end + + local maxPages = 5 -- broaden search depth to improve odds + if opts and typeof(opts.maxPages) == "number" then + maxPages = math.clamp(math.floor(opts.maxPages), 1, 10) + end + + local candidates = {} + local seenAsset = {} + for pageIndex = 0, maxPages - 1 do + local okPage, pages = pcall(function() + return InsertService:GetFreeModels(query, pageIndex) + end) + if okPage then + local page = (pages or {})[1] + if page and typeof(page) == "table" and typeof(page.Results) == "table" then + for resultIndex, entry in ipairs(page.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip non-Roblox entries when filter is active + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + else + warn(string.format("[Vector] GetFreeModels failed page=%d err=%s", pageIndex, tostring(pages))) + end + end + + if #candidates == 0 then + local trimmed = tostring(query or ""):gsub("^%s+", ""):gsub("%s+$", "") + local first = string.match(trimmed, "^([^%s]+)") + if first and string.lower(first) ~= string.lower(trimmed) then + -- Broaden by retrying with the first word (e.g., "hospital" from "hospital building") + for pageIndex = 0, maxPages - 1 do + local okPage2, pages2 = pcall(function() + return InsertService:GetFreeModels(first, pageIndex) + end) + if okPage2 then + local page2 = (pages2 or {})[1] + if page2 and typeof(page2) == "table" and typeof(page2.Results) == "table" then + for resultIndex, entry in ipairs(page2.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + end + end + end + end + + if #candidates == 0 then + local msg = string.format("Failed to find \"%s\" in the marketplace!", query) + warn(msg) + return false, msg + end + + table.sort(candidates, function(a, b) + local scoreA = a.__score or 0 + local scoreB = b.__score or 0 + if scoreA == scoreB then + return (assetIdKey(a.AssetId) or 0) < (assetIdKey(b.AssetId) or 0) + end + return scoreA < scoreB + end) + + local lastErr = nil + for _, entry in ipairs(candidates) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not FAILED_ASSET_IDS[assetId] then + local okLoad, container = pcall(function() + return InsertService:LoadAsset(assetId) + end) + if okLoad and container then + local model = container:FindFirstChildWhichIsA("Model") or container + pcall(function() + if typeof(entry.Name) == "string" and #entry.Name > 0 then + model.Name = entry.Name + else + model.Name = query + end + end) + model.Parent = parent + pcall(function() + local target = origin + offset + model:PivotTo(CFrame.new(target)) + end) + if container ~= model then pcall(function() container:Destroy() end) end + clearAssetFailure(assetId) + return true, model, assetId + else + local errText = okLoad and "load_failed" or container + lastErr = errText + recordAssetFailure(assetId, tostring(errText)) + end + else + lastErr = lastErr or "cached_failure" + end + end + local errMsg = lastErr and tostring(lastErr) or string.format("No insertable free model found for \"%s\"", query) + warn(errMsg) + return false, errMsg +end + local function buildUI(gui) print("[Vector] building UI") gui:ClearAllChildren() @@ -1700,13 +2000,19 @@ local function buildUI(gui) return ui end -local function renderAssetResults(container, p, results) + local function renderAssetResults(container, p, results) container:ClearAllChildren() + local ordered = {} + if type(results) == "table" then + ordered = sortCatalogResults(results) + else + ordered = results + end local layout = Instance.new("UIListLayout") layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Padding = UDim.new(0, 4) layout.Parent = container - for i, r in ipairs(results) do + for i, r in ipairs(ordered) do local row = Instance.new("Frame") row.Size = UDim2.new(1, -8, 0, 28) row.BackgroundTransparency = 0.4 @@ -1740,18 +2046,41 @@ local function renderAssetResults(container, p, results) insertBtn.ZIndex = 2 insertBtn.Parent = row - insertBtn.MouseButton1Click:Connect(function() - local ok, modelOrErr = insertAsset(r.id, p.insert and p.insert.parentPath or nil) - if ok then - row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = modelOrErr:GetFullName() }) - else - row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = tostring(modelOrErr) }) - end - end) + insertBtn.MouseButton1Click:Connect(function() + -- Try this result first, then fall back to trying others in order until one inserts + local function tryInsert(id) + local ok, modelOrErr = insertAsset(id, p.insert and p.insert.parentPath or nil) + if ok then + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + return true, insertedPath + end + return false, tostring(modelOrErr) + end + + local ok, insertedPath = tryInsert(r.id) + if not ok then + for _, alt in ipairs(ordered) do + if alt.id ~= r.id then + ok, insertedPath = tryInsert(alt.id) + if ok then break end + end + end + end + + if ok then + row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = insertedPath }) + else + row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = insertedPath }) + end + end) end end @@ -2302,31 +2631,42 @@ local function toggleDock() end elseif p.search then -- Auto-mode: run a single search, insert the best match under a sensible parent - if _G.__VECTOR_AUTO then - local query = p.search.query or "" - ui.addStatus("auto.asset_search " .. tostring(query)) - local okFetch, resultsOrErr = fetchAssets(query, p.search.limit or 6, p.search.tags) - if okFetch and type(resultsOrErr) == "table" then - if #resultsOrErr > 0 then - local first = resultsOrErr[1] - local assetId = tonumber(first and first.id) + if _G.__VECTOR_AUTO then + local query = p.search.query or "" + ui.addStatus("auto.asset_search " .. tostring(query)) + if ROBLOX_ONLY then ui.addStatus("asset.filter roblox_only") end + -- Fetch a broader candidate set from backend; we will filter/order locally + local desiredLimit = tonumber(p.search.limit) or 0 + local fetchLimit = math.max(60, desiredLimit, 0) + local okFetch, resultsOrErr = fetchAssets(query, fetchLimit, p.search.tags) + if okFetch and type(resultsOrErr) == "table" then + local orderedResults = sortCatalogResults(resultsOrErr) + local desiredParentPath = nil + local sel = Selection:Get() + if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then + local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + if not desiredParentPath then + local hosp = workspace:FindFirstChild("Hospital") + if hosp and hosp.GetFullName then + local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + end + + ensurePermissionWithStatus() + local lastErr = nil + local inserted = false + for _, entry in ipairs(orderedResults) do + local assetId = tonumber(entry and entry.id) if assetId then - -- Choose a default parent: selected container → Workspace.Hospital → Workspace - local desiredParentPath = nil - local sel = Selection:Get() - if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then - local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end - if not desiredParentPath then - local hosp = workspace:FindFirstChild("Hospital") - if hosp and hosp.GetFullName then - local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end + if FAILED_ASSET_IDS[assetId] then + ui.addStatus("auto.asset_search skip cached " .. tostring(assetId)) + lastErr = FAILED_ASSET_IDS[assetId] + continue end ui.addStatus("auto.asset_search insert " .. tostring(assetId) .. (desiredParentPath and (" → " .. desiredParentPath) or "")) - ensurePermissionWithStatus() local okInsert, modelOrErr = insertAsset(assetId, desiredParentPath) if okInsert then _G.__VECTOR_LAST_ASSET_ERROR = nil @@ -2340,20 +2680,42 @@ local function toggleDock() return true else local errMsg = tostring(modelOrErr) + lastErr = errMsg _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" ui.addStatus("auto.err asset " .. errMsg) reportApply(p.id, { ok = false, type = p.type, op = "search_insert", assetId = assetId, query = query, parentPath = desiredParentPath, error = errMsg }) end + end + end + + if not inserted and typeof(query) == "string" and #query > 0 then + ui.addStatus("auto.asset_search fallback free model") + local okFallback, modelOrErr, fallbackAssetId = insertAssetFromFreeModels(query, desiredParentPath, nil, { maxPages = 3 }) + if okFallback then + _G.__VECTOR_LAST_ASSET_ERROR = nil + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + ui.addStatus("auto.ok asset (fallback)") + reportApply(p.id, { ok = true, type = p.type, op = "search_insert_fallback", assetId = fallbackAssetId, insertedPath = insertedPath, query = query, parentPath = desiredParentPath }) + return true else - ui.addStatus("auto.err asset invalid id") - _G.__VECTOR_LAST_ASSET_ERROR = "invalid_asset_id" - reportApply(p.id, { ok = false, type = p.type, op = "search_insert", query = query, error = "invalid_asset_id" }) + local errMsg = tostring(modelOrErr) + lastErr = lastErr or errMsg + _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" + ui.addStatus("auto.err asset " .. errMsg) + reportApply(p.id, { ok = false, type = p.type, op = "search_insert_fallback", query = query, parentPath = desiredParentPath, error = errMsg }) end - else - ui.addStatus("auto.asset_search none") - _G.__VECTOR_LAST_ASSET_ERROR = "no_results" - reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) end + + if lastErr then + return false + end + ui.addStatus("auto.asset_search none") + _G.__VECTOR_LAST_ASSET_ERROR = "no_results" + reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) else local errMsg = typeof(resultsOrErr) == "string" and resultsOrErr or "fetch_failed" _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "fetch_failed" @@ -2382,10 +2744,7 @@ local function maybeAutoContinue(workflowId) steps += 1 ui.addStatus("auto.next step " .. tostring(steps)) local followup = "Next step: propose exactly one small, safe action." - if _G.__VECTOR_LAST_ASSET_ERROR then - followup = followup .. " Asset search/insert failed (" .. tostring(_G.__VECTOR_LAST_ASSET_ERROR) .. "). Please build manually using create_instance/set_properties, or write Luau to rebuild the scene." - _G.__VECTOR_LAST_ASSET_ERROR = nil - end + _G.__VECTOR_LAST_ASSET_ERROR = nil local mode = CURRENT_MODE local opts = { mode = mode, maxTurns = (mode == "ask") and 1 or nil, enableFallbacks = true, modelOverride = getModelOverride(), autoApply = _G.__VECTOR_AUTO } local resp = sendChat("local", followup, buildContextSnapshot(), workflowId, opts) @@ -2915,6 +3274,69 @@ local function resolveByFullName(path) return cur end +local function ensureParentExists(path) + if type(path) ~= "string" or #path == 0 then return nil end + + local function tokenize(full) + local tokens = {} + local buf, inBr = {}, false + local function flush() + if #buf > 0 then + table.insert(tokens, table.concat(buf)) + buf = {} + end + end + for i = 1, #full do + local ch = string.sub(full, i, i) + if ch == "[" then + inBr = true + elseif ch == "]" then + inBr = false + elseif ch == "." and not inBr then + flush() + else + table.insert(buf, ch) + end + end + flush() + return tokens + end + + local tokens = tokenize(path) + if tokens[1] == "game" then + table.remove(tokens, 1) + end + if #tokens == 0 then return nil end + + local rootName = tokens[1] + local okService, current = pcall(function() + return game:GetService(rootName) + end) + if not okService or not current then + current = game:FindFirstChild(rootName) + if not current then + return nil + end + end + + for i = 2, #tokens do + local name = tokens[i] + local child = current:FindFirstChild(name) + if not child then + if current == workspace or current:IsDescendantOf(workspace) then + child = Instance.new("Model") + child.Name = name + child.Parent = current + else + return nil + end + end + current = child + end + + return current +end + local function deserialize(v) if type(v) ~= "table" or v.__t == nil then return v end local t = v.__t @@ -2976,12 +3398,20 @@ return function(className, parentPath, props) if type(className) ~= "string" then return { ok = false, error = "Missing className" } end - local parent = resolveByFullName(parentPath) + local parent = resolveByFullName(parentPath) or ensureParentExists(parentPath) or resolveByFullName(parentPath) if not parent then return { ok = false, error = "Parent not found: " .. tostring(parentPath) } end local startedRecording = ChangeHistoryService:TryBeginRecording("Vector Create", "Vector Create") local ok, res = pcall(function() + -- Idempotency: if a child with the target Name and class already exists, reuse it + local desiredName = type(props) == "table" and props.Name + if type(desiredName) == "string" and #desiredName > 0 then + local existing = parent:FindFirstChild(desiredName) + if existing and existing.ClassName == className then + return existing:GetFullName() + end + end local inst = Instance.new(className) if type(props) == "table" then for k, v in pairs(props) do diff --git a/vector/plugin/src/main.server.lua b/vector/plugin/src/main.server.lua index 6957e15..2d8e9ec 100644 --- a/vector/plugin/src/main.server.lua +++ b/vector/plugin/src/main.server.lua @@ -30,6 +30,18 @@ _G.__VECTOR_PROGRESS = _G.__VECTOR_PROGRESS or 0 _G.__VECTOR_RUNS = _G.__VECTOR_RUNS or {} _G.__VECTOR_LAST_WORKFLOW_ID = _G.__VECTOR_LAST_WORKFLOW_ID or nil +local FAILED_ASSET_IDS = _G.__VECTOR_FAILED_ASSET_IDS +if type(FAILED_ASSET_IDS) ~= "table" then + FAILED_ASSET_IDS = {} + _G.__VECTOR_FAILED_ASSET_IDS = FAILED_ASSET_IDS +end + +local GAME_CREATOR_ID = tonumber(game.CreatorId) or 0 +local GAME_CREATOR_TYPE = tostring(game.CreatorType) + +-- TEMP: Restrict to Roblox-owned assets only +local ROBLOX_ONLY = false + -- UI state (shared across UI and actions) local CURRENT_MODE = "agent" -- or "ask" @@ -590,6 +602,15 @@ local function renderUnifiedDiff(container, oldText, newText) container.CanvasSize = UDim2.new(0, 0, 0, container.UIListLayout.AbsoluteContentSize.Y + 8) end +local function openScriptByPath(path) + local inst = resolveByFullName(path) + if inst then + pcall(function() + ScriptEditorService:OpenScript(inst) + end) + end +end + local function renderConflictDetails(container, files) container:ClearAllChildren() local layout = Instance.new("UIListLayout") @@ -700,15 +721,6 @@ local function applyEditProposal(proposal) return true, nil, results end -local function openScriptByPath(path) - local inst = resolveByFullName(path) - if inst then - pcall(function() - ScriptEditorService:OpenScript(inst) - end) - end -end - local function getBackendBaseUrl() -- Prefer plugin setting if present, fallback to local dev server local val @@ -770,7 +782,29 @@ local function fetchAssets(query, limit, tags) if not ok then return false, "Invalid JSON" end - return true, json.results or {} + local raw = json.results or {} + local filtered = {} + for _, entry in ipairs(raw) do + local assetId = assetIdKey(entry and (entry.id or entry.assetId or entry.AssetId)) + if assetId and assetId > 0 and assetId < 100000000000 then + local typeName = nil + if typeof(entry.type) == "string" then + typeName = string.lower(entry.type) + elseif typeof(entry.AssetType) == "string" then + typeName = string.lower(entry.AssetType) + elseif typeof(entry.assetType) == "string" then + typeName = string.lower(entry.assetType) + elseif typeof(entry.assetType) == "number" then + typeName = tostring(entry.assetType) + end + local isModelLike = typeName == nil or string.find(typeName, "model", 1, true) ~= nil or typeName == "8" + if isModelLike then + entry.id = assetId + table.insert(filtered, entry) + end + end + end + return true, filtered end local function checkpointsBaseUrl() @@ -813,6 +847,123 @@ local function restoreCheckpointRequest(checkpointId, mode) return true, parsed.checkpoint end +local function assetIdKey(assetId) + if typeof(assetId) == "number" then + return assetId + end + local asNumber = tonumber(assetId) + if typeof(asNumber) == "number" and asNumber == asNumber then + return asNumber + end + return nil +end + +local function recordAssetFailure(assetId, message) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = message or true +end + +local function clearAssetFailure(assetId) + local key = assetIdKey(assetId) + if not key then return end + FAILED_ASSET_IDS[key] = nil +end + +local function isRobloxCreatorName(name) + if typeof(name) ~= "string" then return false end + local lower = string.lower(name) + return lower == "roblox" or lower == "roblox team" or lower == "roblox official" or string.sub(lower, 1, 7) == "roblox " +end + +local function isRobloxEntry(entry) + local creatorName = (typeof(entry.creator) == "string" and entry.creator) + or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then creatorType = trimmed end + end + if isRobloxCreatorName(creatorName) then return true end + if creatorId == 1 then return true end + if creatorType == "roblox" then return true end + return false +end + +local function computeCatalogScore(entry, index) + local score = (index or 0) * 10 + local creatorName = (typeof(entry.creator) == "string" and entry.creator) or (typeof(entry.CreatorName) == "string" and entry.CreatorName) or "" + local creatorId = assetIdKey(entry.creatorId or entry.CreatorId) + local creatorType + if typeof(entry.creatorType) == "string" then + creatorType = string.lower(entry.creatorType) + elseif typeof(entry.CreatorType) == "string" then + creatorType = string.lower(entry.CreatorType) + elseif typeof(entry.CreatorType) == "EnumItem" then + creatorType = string.lower(tostring(entry.CreatorType)) + end + if creatorType then + local trimmed = string.match(creatorType, "([^.]+)$") + if trimmed then + creatorType = trimmed + end + end + local isVerified = entry.isVerified == true or entry.IsVerified == true or entry.creatorHasVerifiedBadge == true + + local assetId = assetIdKey(entry.id or entry.AssetId) + if assetId and FAILED_ASSET_IDS[assetId] then + score += 100000 + end + + if isRobloxCreatorName(creatorName) or (creatorType == "roblox") or (creatorId == 1) then + score = score - 8000 + end + + if creatorId and creatorId == GAME_CREATOR_ID then + score = score - 6000 + elseif GAME_CREATOR_TYPE == tostring(Enum.CreatorType.Group) and creatorType == "group" and creatorId == GAME_CREATOR_ID then + score = score - 4000 + end + + if isVerified then + score = score - 400 + end + + return score +end + +local function sortCatalogResults(results) + local scored = {} + -- Optional strict Roblox-only filter + if ROBLOX_ONLY then + local filtered = {} + for _, e in ipairs(results) do + if isRobloxEntry(e) then table.insert(filtered, e) end + end + results = filtered + end + for index, entry in ipairs(results) do + table.insert(scored, { entry = entry, score = computeCatalogScore(entry, index) }) + end + table.sort(scored, function(a, b) + if a.score == b.score then return (a.entry.id or a.entry.AssetId or 0) < (b.entry.id or b.entry.AssetId or 0) end + return a.score < b.score + end) + local ordered = {} + for _, info in ipairs(scored) do + table.insert(ordered, info.entry) + end + return ordered +end + local function insertAsset(assetId, parentPath) local parent = workspace if parentPath then @@ -821,21 +972,170 @@ local function insertAsset(assetId, parentPath) parent = resolved end end + local key = assetIdKey(assetId) + if not key then + return false, "invalid_asset_id" + end + if FAILED_ASSET_IDS[key] then + return false, FAILED_ASSET_IDS[key] + end local ok, modelOrErr = pcall(function() local container = InsertService:LoadAsset(assetId) if not container then error("LoadAsset returned nil") end local model = container:FindFirstChildOfClass("Model") or container model.Parent = parent - -- If the container is different from the inserted model, clean it up if container ~= model then pcall(function() container:Destroy() end) end return model end) if not ok then + recordAssetFailure(assetId, tostring(modelOrErr)) return false, modelOrErr end + clearAssetFailure(assetId) return true, modelOrErr end +local function insertAssetFromFreeModels(query, parentPath, relativePosition, opts) + if typeof(query) ~= "string" or #query == 0 then + return false, "invalid_query" + end + local parent = workspace + if parentPath then + local resolved = resolveByFullName(parentPath) + if resolved and resolved:IsA("Instance") then + parent = resolved + end + end + local cam = workspace.CurrentCamera + local origin = cam and cam.CFrame and cam.CFrame.Position or Vector3.new() + local offset + if typeof(relativePosition) == "Vector3" then + offset = relativePosition + elseif cam and cam.CFrame then + offset = cam.CFrame.LookVector * 16 + else + offset = Vector3.new() + end + + local maxPages = 5 -- broaden search depth to improve odds + if opts and typeof(opts.maxPages) == "number" then + maxPages = math.clamp(math.floor(opts.maxPages), 1, 10) + end + + local candidates = {} + local seenAsset = {} + for pageIndex = 0, maxPages - 1 do + local okPage, pages = pcall(function() + return InsertService:GetFreeModels(query, pageIndex) + end) + if okPage then + local page = (pages or {})[1] + if page and typeof(page) == "table" and typeof(page.Results) == "table" then + for resultIndex, entry in ipairs(page.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip non-Roblox entries when filter is active + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + else + warn(string.format("[Vector] GetFreeModels failed page=%d err=%s", pageIndex, tostring(pages))) + end + end + + if #candidates == 0 then + local trimmed = tostring(query or ""):gsub("^%s+", ""):gsub("%s+$", "") + local first = string.match(trimmed, "^([^%s]+)") + if first and string.lower(first) ~= string.lower(trimmed) then + -- Broaden by retrying with the first word (e.g., "hospital" from "hospital building") + for pageIndex = 0, maxPages - 1 do + local okPage2, pages2 = pcall(function() + return InsertService:GetFreeModels(first, pageIndex) + end) + if okPage2 then + local page2 = (pages2 or {})[1] + if page2 and typeof(page2) == "table" and typeof(page2.Results) == "table" then + for resultIndex, entry in ipairs(page2.Results) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not seenAsset[assetId] then + if ROBLOX_ONLY and not isRobloxEntry(entry) then + -- skip + else + seenAsset[assetId] = true + entry.__score = computeCatalogScore(entry, pageIndex * 100 + resultIndex) + entry.__pageIndex = pageIndex + entry.__resultIndex = resultIndex + table.insert(candidates, entry) + end + end + end + end + end + end + end + end + + if #candidates == 0 then + local msg = string.format("Failed to find \"%s\" in the marketplace!", query) + warn(msg) + return false, msg + end + + table.sort(candidates, function(a, b) + local scoreA = a.__score or 0 + local scoreB = b.__score or 0 + if scoreA == scoreB then + return (assetIdKey(a.AssetId) or 0) < (assetIdKey(b.AssetId) or 0) + end + return scoreA < scoreB + end) + + local lastErr = nil + for _, entry in ipairs(candidates) do + local assetId = entry and assetIdKey(entry.AssetId) + if assetId and not FAILED_ASSET_IDS[assetId] then + local okLoad, container = pcall(function() + return InsertService:LoadAsset(assetId) + end) + if okLoad and container then + local model = container:FindFirstChildWhichIsA("Model") or container + pcall(function() + if typeof(entry.Name) == "string" and #entry.Name > 0 then + model.Name = entry.Name + else + model.Name = query + end + end) + model.Parent = parent + pcall(function() + local target = origin + offset + model:PivotTo(CFrame.new(target)) + end) + if container ~= model then pcall(function() container:Destroy() end) end + clearAssetFailure(assetId) + return true, model, assetId + else + local errText = okLoad and "load_failed" or container + lastErr = errText + recordAssetFailure(assetId, tostring(errText)) + end + else + lastErr = lastErr or "cached_failure" + end + end + local errMsg = lastErr and tostring(lastErr) or string.format("No insertable free model found for \"%s\"", query) + warn(errMsg) + return false, errMsg +end + local function buildUI(gui) print("[Vector] building UI") gui:ClearAllChildren() @@ -1690,13 +1990,19 @@ local function buildUI(gui) return ui end -local function renderAssetResults(container, p, results) + local function renderAssetResults(container, p, results) container:ClearAllChildren() + local ordered = {} + if type(results) == "table" then + ordered = sortCatalogResults(results) + else + ordered = results + end local layout = Instance.new("UIListLayout") layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Padding = UDim.new(0, 4) layout.Parent = container - for i, r in ipairs(results) do + for i, r in ipairs(ordered) do local row = Instance.new("Frame") row.Size = UDim2.new(1, -8, 0, 28) row.BackgroundTransparency = 0.4 @@ -1730,18 +2036,41 @@ local function renderAssetResults(container, p, results) insertBtn.ZIndex = 2 insertBtn.Parent = row - insertBtn.MouseButton1Click:Connect(function() - local ok, modelOrErr = insertAsset(r.id, p.insert and p.insert.parentPath or nil) - if ok then - row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = modelOrErr:GetFullName() }) - else - row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) - row.BackgroundTransparency = 0.2 - reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = tostring(modelOrErr) }) - end - end) + insertBtn.MouseButton1Click:Connect(function() + -- Try this result first, then fall back to trying others in order until one inserts + local function tryInsert(id) + local ok, modelOrErr = insertAsset(id, p.insert and p.insert.parentPath or nil) + if ok then + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + return true, insertedPath + end + return false, tostring(modelOrErr) + end + + local ok, insertedPath = tryInsert(r.id) + if not ok then + for _, alt in ipairs(ordered) do + if alt.id ~= r.id then + ok, insertedPath = tryInsert(alt.id) + if ok then break end + end + end + end + + if ok then + row.BackgroundColor3 = Color3.fromRGB(32, 64, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = true, type = p.type, op = "insert_asset", assetId = r.id, insertedPath = insertedPath }) + else + row.BackgroundColor3 = Color3.fromRGB(64, 32, 32) + row.BackgroundTransparency = 0.2 + reportApply(p.id, { ok = false, type = p.type, op = "insert_asset", assetId = r.id, error = insertedPath }) + end + end) end end @@ -2292,31 +2621,42 @@ local function toggleDock() end elseif p.search then -- Auto-mode: run a single search, insert the best match under a sensible parent - if _G.__VECTOR_AUTO then - local query = p.search.query or "" - ui.addStatus("auto.asset_search " .. tostring(query)) - local okFetch, resultsOrErr = fetchAssets(query, p.search.limit or 6, p.search.tags) - if okFetch and type(resultsOrErr) == "table" then - if #resultsOrErr > 0 then - local first = resultsOrErr[1] - local assetId = tonumber(first and first.id) + if _G.__VECTOR_AUTO then + local query = p.search.query or "" + ui.addStatus("auto.asset_search " .. tostring(query)) + if ROBLOX_ONLY then ui.addStatus("asset.filter roblox_only") end + -- Fetch a broader candidate set from backend; we will filter/order locally + local desiredLimit = tonumber(p.search.limit) or 0 + local fetchLimit = math.max(60, desiredLimit, 0) + local okFetch, resultsOrErr = fetchAssets(query, fetchLimit, p.search.tags) + if okFetch and type(resultsOrErr) == "table" then + local orderedResults = sortCatalogResults(resultsOrErr) + local desiredParentPath = nil + local sel = Selection:Get() + if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then + local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + if not desiredParentPath then + local hosp = workspace:FindFirstChild("Hospital") + if hosp and hosp.GetFullName then + local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) + if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end + end + end + + ensurePermissionWithStatus() + local lastErr = nil + local inserted = false + for _, entry in ipairs(orderedResults) do + local assetId = tonumber(entry and entry.id) if assetId then - -- Choose a default parent: selected container → Workspace.Hospital → Workspace - local desiredParentPath = nil - local sel = Selection:Get() - if type(sel) == "table" and #sel == 1 and sel[1] and sel[1].GetFullName then - local okPath, pathOrErr = pcall(function() return sel[1]:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end - if not desiredParentPath then - local hosp = workspace:FindFirstChild("Hospital") - if hosp and hosp.GetFullName then - local okPath, pathOrErr = pcall(function() return hosp:GetFullName() end) - if okPath and type(pathOrErr) == "string" then desiredParentPath = pathOrErr end - end + if FAILED_ASSET_IDS[assetId] then + ui.addStatus("auto.asset_search skip cached " .. tostring(assetId)) + lastErr = FAILED_ASSET_IDS[assetId] + continue end ui.addStatus("auto.asset_search insert " .. tostring(assetId) .. (desiredParentPath and (" → " .. desiredParentPath) or "")) - ensurePermissionWithStatus() local okInsert, modelOrErr = insertAsset(assetId, desiredParentPath) if okInsert then _G.__VECTOR_LAST_ASSET_ERROR = nil @@ -2330,20 +2670,42 @@ local function toggleDock() return true else local errMsg = tostring(modelOrErr) + lastErr = errMsg _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" ui.addStatus("auto.err asset " .. errMsg) reportApply(p.id, { ok = false, type = p.type, op = "search_insert", assetId = assetId, query = query, parentPath = desiredParentPath, error = errMsg }) end + end + end + + if not inserted and typeof(query) == "string" and #query > 0 then + ui.addStatus("auto.asset_search fallback free model") + local okFallback, modelOrErr, fallbackAssetId = insertAssetFromFreeModels(query, desiredParentPath, nil, { maxPages = 3 }) + if okFallback then + _G.__VECTOR_LAST_ASSET_ERROR = nil + local insertedPath = nil + if modelOrErr and typeof(modelOrErr) == "Instance" and modelOrErr.GetFullName then + local success, value = pcall(function() return modelOrErr:GetFullName() end) + insertedPath = success and value or nil + end + ui.addStatus("auto.ok asset (fallback)") + reportApply(p.id, { ok = true, type = p.type, op = "search_insert_fallback", assetId = fallbackAssetId, insertedPath = insertedPath, query = query, parentPath = desiredParentPath }) + return true else - ui.addStatus("auto.err asset invalid id") - _G.__VECTOR_LAST_ASSET_ERROR = "invalid_asset_id" - reportApply(p.id, { ok = false, type = p.type, op = "search_insert", query = query, error = "invalid_asset_id" }) + local errMsg = tostring(modelOrErr) + lastErr = lastErr or errMsg + _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "insert_failed" + ui.addStatus("auto.err asset " .. errMsg) + reportApply(p.id, { ok = false, type = p.type, op = "search_insert_fallback", query = query, parentPath = desiredParentPath, error = errMsg }) end - else - ui.addStatus("auto.asset_search none") - _G.__VECTOR_LAST_ASSET_ERROR = "no_results" - reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) end + + if lastErr then + return false + end + ui.addStatus("auto.asset_search none") + _G.__VECTOR_LAST_ASSET_ERROR = "no_results" + reportApply(p.id, { ok = false, type = p.type, op = "search", query = query, error = "no_results" }) else local errMsg = typeof(resultsOrErr) == "string" and resultsOrErr or "fetch_failed" _G.__VECTOR_LAST_ASSET_ERROR = errMsg or "fetch_failed" @@ -2372,10 +2734,7 @@ local function maybeAutoContinue(workflowId) steps += 1 ui.addStatus("auto.next step " .. tostring(steps)) local followup = "Next step: propose exactly one small, safe action." - if _G.__VECTOR_LAST_ASSET_ERROR then - followup = followup .. " Asset search/insert failed (" .. tostring(_G.__VECTOR_LAST_ASSET_ERROR) .. "). Please build manually using create_instance/set_properties, or write Luau to rebuild the scene." - _G.__VECTOR_LAST_ASSET_ERROR = nil - end + _G.__VECTOR_LAST_ASSET_ERROR = nil local mode = CURRENT_MODE local opts = { mode = mode, maxTurns = (mode == "ask") and 1 or nil, enableFallbacks = true, modelOverride = getModelOverride(), autoApply = _G.__VECTOR_AUTO } local resp = sendChat("local", followup, buildContextSnapshot(), workflowId, opts) diff --git a/vector/plugin/src/tools/create_instance.lua b/vector/plugin/src/tools/create_instance.lua index ef598a8..06f36ac 100644 --- a/vector/plugin/src/tools/create_instance.lua +++ b/vector/plugin/src/tools/create_instance.lua @@ -47,6 +47,69 @@ local function resolveByFullName(path) return cur end +local function ensureParentExists(path) + if type(path) ~= "string" or #path == 0 then return nil end + + local function tokenize(full) + local tokens = {} + local buf, inBr = {}, false + local function flush() + if #buf > 0 then + table.insert(tokens, table.concat(buf)) + buf = {} + end + end + for i = 1, #full do + local ch = string.sub(full, i, i) + if ch == "[" then + inBr = true + elseif ch == "]" then + inBr = false + elseif ch == "." and not inBr then + flush() + else + table.insert(buf, ch) + end + end + flush() + return tokens + end + + local tokens = tokenize(path) + if tokens[1] == "game" then + table.remove(tokens, 1) + end + if #tokens == 0 then return nil end + + local rootName = tokens[1] + local okService, current = pcall(function() + return game:GetService(rootName) + end) + if not okService or not current then + current = game:FindFirstChild(rootName) + if not current then + return nil + end + end + + for i = 2, #tokens do + local name = tokens[i] + local child = current:FindFirstChild(name) + if not child then + if current == workspace or current:IsDescendantOf(workspace) then + child = Instance.new("Model") + child.Name = name + child.Parent = current + else + return nil + end + end + current = child + end + + return current +end + local function deserialize(v) if type(v) ~= "table" or v.__t == nil then return v end local t = v.__t @@ -108,12 +171,20 @@ return function(className, parentPath, props) if type(className) ~= "string" then return { ok = false, error = "Missing className" } end - local parent = resolveByFullName(parentPath) + local parent = resolveByFullName(parentPath) or ensureParentExists(parentPath) or resolveByFullName(parentPath) if not parent then return { ok = false, error = "Parent not found: " .. tostring(parentPath) } end local startedRecording = ChangeHistoryService:TryBeginRecording("Vector Create", "Vector Create") local ok, res = pcall(function() + -- Idempotency: if a child with the target Name and class already exists, reuse it + local desiredName = type(props) == "table" and props.Name + if type(desiredName) == "string" and #desiredName > 0 then + local existing = parent:FindFirstChild(desiredName) + if existing and existing.ClassName == className then + return existing:GetFullName() + end + end local inst = Instance.new(className) if type(props) == "table" then for k, v in pairs(props) do