Release v0.3.0
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { listAssets, queryAssetData, queryAssetInfo, queryAssetMeta } = require('./assets');
|
||||
const { resolveProjectPath } = require('./path-safety');
|
||||
|
||||
function requestEditorMessage(channel, method, ...args) {
|
||||
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
||||
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
|
||||
}
|
||||
return Editor.Message.request(channel, method, ...args);
|
||||
}
|
||||
|
||||
function assetUrlToPath(projectPath, url) {
|
||||
if (!url || !String(url).startsWith('db://assets/')) {
|
||||
return '';
|
||||
}
|
||||
return path.join(projectPath, String(url).slice('db://'.length));
|
||||
}
|
||||
|
||||
function assetFilePath(projectPath, info) {
|
||||
const candidates = [
|
||||
info && info.file,
|
||||
info && info.path,
|
||||
info && info.source,
|
||||
info && info.url ? assetUrlToPath(projectPath, info.url) : '',
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const fullPath = path.isAbsolute(candidate)
|
||||
? resolveProjectPath(projectPath, candidate)
|
||||
: resolveProjectPath(projectPath, candidate);
|
||||
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function collectUuidReferences(value, refs = [], pointer = '') {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return refs;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => collectUuidReferences(item, refs, `${pointer}/${index}`));
|
||||
return refs;
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const childPointer = `${pointer}/${key}`;
|
||||
if (
|
||||
typeof child === 'string' &&
|
||||
(key.toLowerCase().includes('uuid') || key === '__uuid__' || key === 'assetUuid' || key === 'prefabUuid')
|
||||
) {
|
||||
refs.push({ uuid: child, path: childPointer, key });
|
||||
} else {
|
||||
collectUuidReferences(child, refs, childPointer);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function getByJsonPath(target, jsonPath) {
|
||||
const segments = String(jsonPath || '')
|
||||
.replace(/^\//, '')
|
||||
.split(/[/.]/)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
let current = target;
|
||||
for (const segment of segments) {
|
||||
if (current == null) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setByJsonPath(target, jsonPath, value) {
|
||||
const segments = String(jsonPath || '')
|
||||
.replace(/^\//, '')
|
||||
.split(/[/.]/)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
if (!segments.length) {
|
||||
throw new Error('jsonPath is required.');
|
||||
}
|
||||
let current = target;
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (current[segment] == null || typeof current[segment] !== 'object') {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
current[segments[segments.length - 1]] = value;
|
||||
}
|
||||
|
||||
async function inspectPrefab(projectPath, target) {
|
||||
const info = await queryAssetInfo(target);
|
||||
const meta = await queryAssetMeta(target).catch(() => null);
|
||||
const data = await queryAssetData(target).catch(() => null);
|
||||
const filePath = assetFilePath(projectPath, info);
|
||||
const content = filePath ? fs.readFileSync(filePath, 'utf8') : '';
|
||||
const parsed = content ? JSON.parse(content) : data;
|
||||
const references = collectUuidReferences(parsed).slice(0, 500);
|
||||
|
||||
return {
|
||||
info,
|
||||
meta,
|
||||
filePath: filePath ? path.relative(projectPath, filePath).replace(/\\/g, '/') : '',
|
||||
referenceCount: references.length,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
async function validatePrefabReferences(projectPath, options = {}) {
|
||||
const targets = options.target
|
||||
? [options.target]
|
||||
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: 'cc.Prefab' }))
|
||||
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
|
||||
.map((asset) => asset.uuid || asset.url)
|
||||
.filter(Boolean);
|
||||
const prefabs = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const prefab = await inspectPrefab(projectPath, target);
|
||||
const checked = [];
|
||||
const missing = [];
|
||||
for (const ref of prefab.references) {
|
||||
try {
|
||||
const info = await queryAssetInfo(ref.uuid);
|
||||
checked.push({ ...ref, exists: true, asset: { uuid: info.uuid, url: info.url, type: info.type } });
|
||||
} catch (error) {
|
||||
missing.push({ ...ref, exists: false, error: error.message });
|
||||
}
|
||||
}
|
||||
prefabs.push({
|
||||
target,
|
||||
filePath: prefab.filePath,
|
||||
referenceCount: prefab.referenceCount,
|
||||
checkedCount: checked.length + missing.length,
|
||||
missingCount: missing.length,
|
||||
missing,
|
||||
});
|
||||
}
|
||||
|
||||
const missingCount = prefabs.reduce((sum, prefab) => sum + prefab.missingCount, 0);
|
||||
return {
|
||||
ok: missingCount === 0,
|
||||
prefabCount: prefabs.length,
|
||||
missingCount,
|
||||
prefabs,
|
||||
};
|
||||
}
|
||||
|
||||
async function duplicatePrefab(projectPath, options = {}) {
|
||||
const source = String(options.source || '').trim();
|
||||
const target = String(options.target || '').trim();
|
||||
if (!source || !target) {
|
||||
throw new Error('source and target are required.');
|
||||
}
|
||||
|
||||
const info = await queryAssetInfo(source);
|
||||
const sourcePath = assetFilePath(projectPath, info);
|
||||
if (!sourcePath) {
|
||||
throw new Error(`Prefab source file was not found: ${source}`);
|
||||
}
|
||||
|
||||
const targetPath = resolveProjectPath(projectPath, target.endsWith('.prefab') ? target : `${target}.prefab`);
|
||||
const assetsRoot = path.join(projectPath, 'assets');
|
||||
const relativeToAssets = path.relative(assetsRoot, targetPath);
|
||||
if (relativeToAssets.startsWith('..') || path.isAbsolute(relativeToAssets)) {
|
||||
throw new Error('target must be inside the Cocos assets directory.');
|
||||
}
|
||||
if (fs.existsSync(targetPath) && options.overwrite !== true) {
|
||||
throw new Error(`Target prefab already exists: ${target}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
return {
|
||||
duplicated: true,
|
||||
source: path.relative(projectPath, sourcePath).replace(/\\/g, '/'),
|
||||
target: path.relative(projectPath, targetPath).replace(/\\/g, '/'),
|
||||
};
|
||||
}
|
||||
|
||||
async function editPrefabJson(projectPath, options = {}) {
|
||||
const target = String(options.target || '').trim();
|
||||
if (!target) {
|
||||
throw new Error('target is required.');
|
||||
}
|
||||
const info = await queryAssetInfo(target);
|
||||
const filePath = assetFilePath(projectPath, info);
|
||||
if (!filePath) {
|
||||
throw new Error(`Prefab file was not found: ${target}`);
|
||||
}
|
||||
|
||||
const original = fs.readFileSync(filePath, 'utf8');
|
||||
let updated = original;
|
||||
if (options.search !== undefined) {
|
||||
const search = String(options.search);
|
||||
if (!search) {
|
||||
throw new Error('search must not be empty.');
|
||||
}
|
||||
if (!original.includes(search)) {
|
||||
throw new Error('search text was not found in prefab file.');
|
||||
}
|
||||
updated = options.replaceAll
|
||||
? original.split(search).join(String(options.replace || ''))
|
||||
: original.replace(search, String(options.replace || ''));
|
||||
} else {
|
||||
const json = JSON.parse(original);
|
||||
const value = JSON.parse(String(options.valueJson || 'null'));
|
||||
setByJsonPath(json, options.jsonPath, value);
|
||||
updated = JSON.stringify(json, null, 2) + '\n';
|
||||
}
|
||||
|
||||
JSON.parse(updated);
|
||||
if (options.createBackup) {
|
||||
fs.writeFileSync(`${filePath}.bak`, original, 'utf8');
|
||||
}
|
||||
fs.writeFileSync(filePath, updated, 'utf8');
|
||||
return {
|
||||
edited: true,
|
||||
path: path.relative(projectPath, filePath).replace(/\\/g, '/'),
|
||||
oldValue: options.jsonPath ? getByJsonPath(JSON.parse(original), options.jsonPath) : undefined,
|
||||
validation: await validatePrefabReferences(projectPath, { target }),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyPrefabInstance(nodeUuid) {
|
||||
const uuid = String(nodeUuid || '').trim();
|
||||
if (!uuid) {
|
||||
throw new Error('node uuid is required.');
|
||||
}
|
||||
const result = await requestEditorMessage('scene', 'apply-prefab', uuid);
|
||||
return { applied: true, uuid, result };
|
||||
}
|
||||
|
||||
async function revertPrefabInstance(nodeUuid) {
|
||||
const uuid = String(nodeUuid || '').trim();
|
||||
if (!uuid) {
|
||||
throw new Error('node uuid is required.');
|
||||
}
|
||||
const candidates = ['revert-prefab', 'restore-prefab'];
|
||||
let lastError = null;
|
||||
for (const method of candidates) {
|
||||
try {
|
||||
const result = await requestEditorMessage('scene', method, uuid);
|
||||
return { reverted: true, uuid, method, result };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('No prefab revert editor message was available.');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyPrefabInstance,
|
||||
duplicatePrefab,
|
||||
editPrefabJson,
|
||||
inspectPrefab,
|
||||
revertPrefabInstance,
|
||||
validatePrefabReferences,
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { resolveProjectPath } = require('./path-safety');
|
||||
|
||||
const KNOWN_INSTRUCTION_PATHS = [
|
||||
'AGENTS.md',
|
||||
'CLAUDE.md',
|
||||
'GEMINI.md',
|
||||
'.cursorrules',
|
||||
'.windsurfrules',
|
||||
'.github/copilot-instructions.md',
|
||||
];
|
||||
|
||||
function normalizeSkillName(value) {
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
if (!normalized) {
|
||||
throw new Error('skillName is required.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function statFile(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listSkillFiles(projectPath) {
|
||||
const skillRoot = resolveProjectPath(projectPath, '.codex/skills');
|
||||
if (!fs.existsSync(skillRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills = [];
|
||||
const stack = [skillRoot];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.name === 'SKILL.md') {
|
||||
const stat = statFile(fullPath);
|
||||
skills.push({
|
||||
path: path.relative(projectPath, fullPath).replace(/\\/g, '/'),
|
||||
size: stat ? stat.size : 0,
|
||||
mtime: stat ? stat.mtime.toISOString() : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return skills.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
function listProjectInstructions(projectPath) {
|
||||
const files = [];
|
||||
for (const relativePath of KNOWN_INSTRUCTION_PATHS) {
|
||||
const fullPath = resolveProjectPath(projectPath, relativePath);
|
||||
const stat = statFile(fullPath);
|
||||
if (stat && stat.isFile()) {
|
||||
files.push({
|
||||
path: relativePath,
|
||||
size: stat.size,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
skills: listSkillFiles(projectPath),
|
||||
};
|
||||
}
|
||||
|
||||
function readProjectInstruction(projectPath, target) {
|
||||
const relativePath = String(target || '').trim();
|
||||
if (!relativePath) {
|
||||
throw new Error('target is required.');
|
||||
}
|
||||
const fullPath = resolveProjectPath(projectPath, relativePath);
|
||||
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
||||
throw new Error(`Instruction file not found: ${relativePath}`);
|
||||
}
|
||||
return {
|
||||
path: relativePath,
|
||||
content: fs.readFileSync(fullPath, 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
function writeProjectInstruction(projectPath, options = {}) {
|
||||
const relativePath = String(options.target || '').trim();
|
||||
if (!relativePath) {
|
||||
throw new Error('target is required.');
|
||||
}
|
||||
const content = String(options.content || '');
|
||||
const fullPath = resolveProjectPath(projectPath, relativePath);
|
||||
if (fs.existsSync(fullPath) && options.overwrite === false) {
|
||||
throw new Error(`Instruction file already exists: ${relativePath}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
const stat = fs.statSync(fullPath);
|
||||
return {
|
||||
written: true,
|
||||
path: relativePath,
|
||||
size: stat.size,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectSkill(projectPath, options = {}) {
|
||||
const skillName = normalizeSkillName(options.skillName);
|
||||
const title = String(options.title || skillName).trim();
|
||||
const description = String(options.description || `Project-specific workflow for ${title}.`).trim();
|
||||
const body = String(options.instructions || '').trim() || [
|
||||
`Use this skill for ${title} work in this Cocos project.`,
|
||||
'',
|
||||
'- Inspect the active scene and project context before editing.',
|
||||
'- Prefer focused MCP tools before broad manual file edits.',
|
||||
'- Run relevant validation tools after changes.',
|
||||
].join('\n');
|
||||
const relativePath = `.codex/skills/${skillName}/SKILL.md`;
|
||||
const content = [
|
||||
`# ${title}`,
|
||||
'',
|
||||
`Description: ${description}`,
|
||||
'',
|
||||
'## Instructions',
|
||||
body,
|
||||
'',
|
||||
].join('\n');
|
||||
return writeProjectInstruction(projectPath, {
|
||||
target: relativePath,
|
||||
content,
|
||||
overwrite: options.overwrite !== false,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KNOWN_INSTRUCTION_PATHS,
|
||||
createProjectSkill,
|
||||
listProjectInstructions,
|
||||
readProjectInstruction,
|
||||
writeProjectInstruction,
|
||||
};
|
||||
+7
-2
@@ -570,10 +570,15 @@ class McpServer {
|
||||
}
|
||||
return this.createResult(request.id, result);
|
||||
} catch (error) {
|
||||
return this.createResult(request.id, {
|
||||
const result = {
|
||||
content: textContent(error.message),
|
||||
isError: true,
|
||||
});
|
||||
};
|
||||
const structured = structuredContent(error.toolEnvelope);
|
||||
if (structured) {
|
||||
result.structuredContent = structured;
|
||||
}
|
||||
return this.createResult(request.id, result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+430
-4
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
@@ -22,9 +23,24 @@ const {
|
||||
searchProjectLogs,
|
||||
} = require('./logs');
|
||||
const { resolveProjectPath } = require('./path-safety');
|
||||
const {
|
||||
createProjectSkill,
|
||||
listProjectInstructions,
|
||||
readProjectInstruction,
|
||||
writeProjectInstruction,
|
||||
} = require('./project-instructions');
|
||||
const {
|
||||
applyPrefabInstance,
|
||||
duplicatePrefab,
|
||||
editPrefabJson,
|
||||
inspectPrefab,
|
||||
revertPrefabInstance,
|
||||
validatePrefabReferences,
|
||||
} = require('./prefabs');
|
||||
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
|
||||
const { checkForUpdate } = require('./update-checker');
|
||||
const { safeStringify } = require('./utils');
|
||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||
|
||||
const TOOL_CATEGORY_RULES = [
|
||||
['updates', /update/],
|
||||
@@ -35,6 +51,7 @@ const TOOL_CATEGORY_RULES = [
|
||||
['files', /file|directory|exists|refresh_assets/],
|
||||
['assets', /asset|scene$|scenes|open_scene|run_scene_asset/],
|
||||
['prefabs', /prefab/],
|
||||
['instructions', /instruction|skill/],
|
||||
['selection', /selection|select_/],
|
||||
['components', /component/],
|
||||
['ui', /canvas|label|button|sprite/],
|
||||
@@ -57,6 +74,34 @@ function createSchema(properties, required) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
function createOutputSchema(dataSchema = {}) {
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', description: 'Whether the tool call completed successfully.' },
|
||||
tool: { type: 'string', description: 'Tool name that produced this result.' },
|
||||
callId: { type: 'string', description: 'Stable identifier for this tool call result.' },
|
||||
timestamp: { type: 'string', description: 'ISO timestamp when the result envelope was produced.' },
|
||||
summary: { type: 'string', description: 'Short human-readable result summary.' },
|
||||
data: dataSchema,
|
||||
refs: {
|
||||
type: 'array',
|
||||
description: 'Stable references discovered in the result for follow-up tool calls.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
id: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['ok', 'tool', 'callId', 'timestamp', 'data'],
|
||||
};
|
||||
}
|
||||
|
||||
function inferToolCategory(toolName) {
|
||||
for (const [category, pattern] of TOOL_CATEGORY_RULES) {
|
||||
if (pattern.test(toolName)) {
|
||||
@@ -86,6 +131,26 @@ function toolCategory(tool) {
|
||||
return tool.category || inferToolCategory(tool.name);
|
||||
}
|
||||
|
||||
function inferToolAnnotations(tool) {
|
||||
const name = tool.name;
|
||||
const category = toolCategory(tool);
|
||||
const readOnly = /^(get|list|inspect|find|read|search|check|validate|exists|capture)/.test(name);
|
||||
const destructive = /(delete|remove|clear|replace|write|reset|set_|execute|run_scene|invoke|emit|simulate)/.test(name);
|
||||
const idempotent = readOnly || /^(set|select|open|pause|resume|stop|refresh)/.test(name);
|
||||
|
||||
return {
|
||||
title: name
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' '),
|
||||
readOnlyHint: readOnly,
|
||||
destructiveHint: readOnly ? false : destructive,
|
||||
idempotentHint: idempotent,
|
||||
openWorldHint: category === 'updates',
|
||||
...(tool.annotations || {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isToolExposed(config, tool) {
|
||||
const profile = config && config.toolProfile === 'full'
|
||||
? 'full'
|
||||
@@ -112,6 +177,115 @@ function isToolExposed(config, tool) {
|
||||
return exposed;
|
||||
}
|
||||
|
||||
function hashObject(value) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(safeStringify(value))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
function summarizeResult(result) {
|
||||
if (typeof result === 'string') {
|
||||
if (result.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||
return 'Image payload returned.';
|
||||
}
|
||||
return result.length > 160 ? `${result.slice(0, 160)}...` : result;
|
||||
}
|
||||
if (!result || typeof result !== 'object') {
|
||||
return String(result);
|
||||
}
|
||||
if (typeof result.summary === 'string') {
|
||||
return result.summary;
|
||||
}
|
||||
for (const key of ['message', 'path', 'url', 'sceneName', 'projectName']) {
|
||||
if (typeof result[key] === 'string' && result[key]) {
|
||||
return `${key}: ${result[key]}`;
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(result.count)) {
|
||||
return `count: ${result.count}`;
|
||||
}
|
||||
return 'Structured result returned.';
|
||||
}
|
||||
|
||||
function normalizeEnvelopeData(result) {
|
||||
if (typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||
return {
|
||||
image: true,
|
||||
mimeType: 'image/png',
|
||||
byteLength: Buffer.byteLength(result.slice(IMAGE_DATA_URI_PREFIX.length), 'base64'),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addRef(refs, type, id, extra = {}) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const key = `${type}:${id}`;
|
||||
if (refs.some((ref) => ref.key === key)) {
|
||||
return;
|
||||
}
|
||||
refs.push({ key, type, id: String(id), ...extra });
|
||||
}
|
||||
|
||||
function collectRefs(value, refs = [], depth = 0, seen = new WeakSet()) {
|
||||
if (!value || depth > 5) {
|
||||
return refs;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectRefs(item, refs, depth + 1, seen);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
return refs;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return refs;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
const uuid = value.uuid || value.prefabUuid || value.sceneUuid || value.assetUuid;
|
||||
const pathValue = value.path || value.node || value.url;
|
||||
if (uuid) {
|
||||
addRef(refs, pathValue && String(pathValue).startsWith('db://') ? 'asset' : 'uuid', uuid, {
|
||||
path: pathValue ? String(pathValue) : undefined,
|
||||
name: value.name ? String(value.name) : undefined,
|
||||
});
|
||||
}
|
||||
if (typeof pathValue === 'string' && pathValue) {
|
||||
addRef(refs, pathValue.startsWith('db://') ? 'asset' : 'path', pathValue, {
|
||||
name: value.name ? String(value.name) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of Object.values(value)) {
|
||||
collectRefs(item, refs, depth + 1, seen);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function createResultEnvelope(tool, args, result, options = {}) {
|
||||
const data = normalizeEnvelopeData(result);
|
||||
const refs = collectRefs(data).map(({ key, ...ref }) => ref);
|
||||
const timestamp = new Date().toISOString();
|
||||
const summary = options.summary || summarizeResult(result);
|
||||
const callId = `fp_${hashObject({ tool: tool.name, args: args || {}, result: data })}`;
|
||||
return {
|
||||
ok: options.ok !== false,
|
||||
tool: tool.name,
|
||||
callId,
|
||||
timestamp,
|
||||
summary,
|
||||
data,
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeDiagnostics(result) {
|
||||
if (!result) {
|
||||
return null;
|
||||
@@ -217,6 +391,17 @@ async function refreshAssets(projectPath, targetPath) {
|
||||
return 'File written outside assets directory; no asset-db refresh was needed.';
|
||||
}
|
||||
|
||||
async function resolveNodeUuid(sceneBridge, args) {
|
||||
if (args && args.uuid) {
|
||||
return String(args.uuid);
|
||||
}
|
||||
const inspected = await sceneBridge.call('inspectNode', args || {});
|
||||
if (!inspected || !inspected.uuid) {
|
||||
throw new Error('Target node uuid could not be resolved.');
|
||||
}
|
||||
return inspected.uuid;
|
||||
}
|
||||
|
||||
function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runtimeLog, sceneBridge, editorExecutor }) {
|
||||
const tools = [
|
||||
{
|
||||
@@ -351,6 +536,67 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
||||
inputSchema: createSchema({}, []),
|
||||
handler: async () => getCurrentSelection(),
|
||||
},
|
||||
{
|
||||
name: 'list_project_instructions',
|
||||
profile: 'core',
|
||||
description: '[specialist] List project AI instruction files and local Codex project skills.',
|
||||
inputSchema: createSchema({}, []),
|
||||
handler: async () => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return listProjectInstructions(projectPath);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'read_project_instruction',
|
||||
profile: 'core',
|
||||
description: '[specialist] Read a project AI instruction file such as AGENTS.md, CLAUDE.md, or a .codex skill SKILL.md.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Project-relative instruction path.' },
|
||||
},
|
||||
['target']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return readProjectInstruction(projectPath, args.target);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'write_project_instruction',
|
||||
profile: 'full',
|
||||
description: '[core] Create or update a project AI instruction file inside the Cocos project.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Project-relative instruction path.' },
|
||||
content: { type: 'string', description: 'Instruction file content.' },
|
||||
overwrite: { type: 'boolean', description: 'Allow overwriting an existing file. Defaults to true.' },
|
||||
},
|
||||
['target', 'content']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return writeProjectInstruction(projectPath, args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_project_skill',
|
||||
profile: 'full',
|
||||
description: '[core] Create a local Codex project skill under .codex/skills/{skillName}/SKILL.md.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
skillName: { type: 'string', description: 'Filesystem-safe project skill name.' },
|
||||
title: { type: 'string', description: 'Human-readable skill title.' },
|
||||
description: { type: 'string', description: 'Skill trigger description.' },
|
||||
instructions: { type: 'string', description: 'Skill instructions body.' },
|
||||
overwrite: { type: 'boolean', description: 'Allow overwriting an existing skill. Defaults to true.' },
|
||||
},
|
||||
['skillName']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return createProjectSkill(projectPath, args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'set_selection',
|
||||
profile: 'core',
|
||||
@@ -537,6 +783,166 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
||||
return { count: assets.length, prefabs: assets.slice(0, 200) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inspect_prefab',
|
||||
profile: 'core',
|
||||
description: '[specialist] Inspect a prefab asset, its metadata, serialized file path, and UUID-like asset references.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
|
||||
},
|
||||
['target']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return await inspectPrefab(projectPath, args.target);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_prefab_references',
|
||||
profile: 'core',
|
||||
description: '[specialist] Validate prefab asset references by checking serialized UUID references against asset-db.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Optional prefab uuid, db url, or path. When omitted, scans prefab assets.' },
|
||||
pattern: { type: 'string', description: 'Optional asset-db pattern used when scanning prefabs.' },
|
||||
limit: { type: 'number', description: 'Maximum prefab assets to scan when target is omitted.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
return await validatePrefabReferences(projectPath, args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'duplicate_prefab',
|
||||
profile: 'full',
|
||||
description: '[core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
source: { type: 'string', description: 'Source prefab uuid, db url, or project path.' },
|
||||
target: { type: 'string', description: 'Project-relative target path under assets, with or without .prefab.' },
|
||||
overwrite: { type: 'boolean', description: 'Overwrite target prefab if it already exists.' },
|
||||
},
|
||||
['source', 'target']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
const result = await duplicatePrefab(projectPath, args);
|
||||
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.target)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit_prefab_json',
|
||||
profile: 'full',
|
||||
description: '[core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
target: { type: 'string', description: 'Prefab uuid, db url, or project path.' },
|
||||
jsonPath: { type: 'string', description: 'JSON path such as /0/_name or 0._name when assigning valueJson.' },
|
||||
valueJson: { type: 'string', description: 'JSON encoded value to assign at jsonPath.' },
|
||||
search: { type: 'string', description: 'Literal text to search for instead of jsonPath assignment.' },
|
||||
replace: { type: 'string', description: 'Replacement text for literal search.' },
|
||||
replaceAll: { type: 'boolean', description: 'Replace all literal matches.' },
|
||||
createBackup: { type: 'boolean', description: 'Create a .bak file before writing.' },
|
||||
},
|
||||
['target']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const { projectPath } = getRuntimeContext();
|
||||
const result = await editPrefabJson(projectPath, args);
|
||||
return { ...result, refresh: await refreshAssets(projectPath, resolveProjectPath(projectPath, result.path)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_prefab_instance',
|
||||
profile: 'full',
|
||||
description: '[core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
prefabUuid: { type: 'string', description: 'Prefab asset uuid, db url, or path.' },
|
||||
parentPath: { type: 'string', description: 'Optional parent node path.' },
|
||||
name: { type: 'string', description: 'Optional override node name.' },
|
||||
position: { type: 'object', description: 'Optional position {x,y,z}; fallback runtime path only.' },
|
||||
},
|
||||
['prefabUuid']
|
||||
),
|
||||
handler: async (args) => {
|
||||
const info = await queryAssetInfo(args.prefabUuid);
|
||||
const payload = {
|
||||
assetUuid: info.uuid || args.prefabUuid,
|
||||
unlinkPrefab: false,
|
||||
};
|
||||
if (args.parentPath) {
|
||||
payload.parent = await resolveNodeUuid(sceneBridge, { path: args.parentPath });
|
||||
}
|
||||
if (args.name) {
|
||||
payload.name = args.name;
|
||||
}
|
||||
|
||||
if (global.Editor && Editor.Message && typeof Editor.Message.request === 'function') {
|
||||
try {
|
||||
const createdUuid = await Editor.Message.request('scene', 'create-node', payload);
|
||||
return {
|
||||
created: true,
|
||||
linkedPrefab: true,
|
||||
prefabUuid: payload.assetUuid,
|
||||
uuid: createdUuid,
|
||||
};
|
||||
} catch (error) {
|
||||
runtimeLog && runtimeLog.add('warn', `Linked prefab create-node failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return await sceneBridge.call('instantiatePrefab', {
|
||||
...args,
|
||||
prefabUuid: info.uuid || args.prefabUuid,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inspect_prefab_instance',
|
||||
profile: 'core',
|
||||
description: '[specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
path: { type: 'string', description: 'Node hierarchy path.' },
|
||||
uuid: { type: 'string', description: 'Node uuid.' },
|
||||
name: { type: 'string', description: 'Fallback exact node name.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => sceneBridge.call('getPrefabInstanceInfo', args),
|
||||
},
|
||||
{
|
||||
name: 'apply_prefab_instance',
|
||||
profile: 'full',
|
||||
description: '[core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
path: { type: 'string', description: 'Node hierarchy path.' },
|
||||
uuid: { type: 'string', description: 'Node uuid.' },
|
||||
name: { type: 'string', description: 'Fallback exact node name.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => await applyPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
|
||||
},
|
||||
{
|
||||
name: 'revert_prefab_instance',
|
||||
profile: 'full',
|
||||
description: '[core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages.',
|
||||
inputSchema: createSchema(
|
||||
{
|
||||
path: { type: 'string', description: 'Node hierarchy path.' },
|
||||
uuid: { type: 'string', description: 'Node uuid.' },
|
||||
name: { type: 'string', description: 'Fallback exact node name.' },
|
||||
},
|
||||
[]
|
||||
),
|
||||
handler: async (args) => await revertPrefabInstance(await resolveNodeUuid(sceneBridge, args)),
|
||||
},
|
||||
{
|
||||
name: 'instantiate_prefab',
|
||||
profile: 'full',
|
||||
@@ -1242,6 +1648,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
||||
includeComponents: true,
|
||||
}).catch((error) => ({ ok: false, error: error.message }));
|
||||
const runtime = await sceneBridge.call('getRuntimeState', {}).catch((error) => ({ ok: false, error: error.message }));
|
||||
const performance = await sceneBridge.call('getPerformanceSnapshot', {}).catch((error) => ({ ok: false, error: error.message }));
|
||||
const diagnostics = args.includeScriptDiagnostics === false
|
||||
? null
|
||||
: summarizeDiagnostics(await runScriptDiagnostics(projectPath, args).catch((error) => ({ ok: false, summary: error.message, diagnostics: [] })));
|
||||
@@ -1250,14 +1657,22 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
||||
: searchProjectLogs(projectPath, { query: 'error', limit: 20 }).matches;
|
||||
|
||||
return {
|
||||
ok: !scene.error && !runtime.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0),
|
||||
ok: !scene.error && !runtime.error && !performance.error && (!diagnostics || diagnostics.ok) && (!logErrors || logErrors.length === 0),
|
||||
scene,
|
||||
runtime,
|
||||
performance,
|
||||
diagnostics,
|
||||
logErrors,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_performance_snapshot',
|
||||
profile: 'core',
|
||||
description: '[specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings.',
|
||||
inputSchema: createSchema({}, []),
|
||||
handler: async (args) => sceneBridge.call('getPerformanceSnapshot', args),
|
||||
},
|
||||
{
|
||||
name: 'get_runtime_state',
|
||||
profile: 'core',
|
||||
@@ -1580,6 +1995,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
|
||||
annotations: inferToolAnnotations(tool),
|
||||
}));
|
||||
},
|
||||
listToolCatalog() {
|
||||
@@ -1589,6 +2006,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
||||
description: tool.description,
|
||||
profile: tool.profile,
|
||||
category: toolCategory(tool),
|
||||
annotations: inferToolAnnotations(tool),
|
||||
outputSchema: tool.outputSchema || createOutputSchema(tool.dataSchema),
|
||||
enabled: isToolExposed(config || {}, tool),
|
||||
}));
|
||||
},
|
||||
@@ -1604,14 +2023,21 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
|
||||
|
||||
try {
|
||||
const result = await tool.handler(args || {});
|
||||
const output = toOutput(result);
|
||||
interactionLog.add(name, 'success', output.slice(0, 500));
|
||||
const envelope = createResultEnvelope(tool, args || {}, result);
|
||||
const output = typeof result === 'string' && result.startsWith(IMAGE_DATA_URI_PREFIX)
|
||||
? result
|
||||
: toOutput(envelope);
|
||||
interactionLog.add(name, 'success', envelope.summary.slice(0, 500));
|
||||
return {
|
||||
value: result,
|
||||
value: envelope,
|
||||
text: output,
|
||||
};
|
||||
} catch (error) {
|
||||
interactionLog.add(name, 'error', error.message);
|
||||
error.toolEnvelope = createResultEnvelope(tool, args || {}, { message: error.message }, {
|
||||
ok: false,
|
||||
summary: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user