feat: enrich Codex skill metadata display

This commit is contained in:
shiyue
2026-06-18 08:42:57 +08:00
parent 216f87e3b4
commit c50ee527ea
5 changed files with 731 additions and 31 deletions

426
server.js
View File

@@ -48,6 +48,9 @@ const COMPOSER_SUGGESTION_LIMIT = 20;
const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024;
const COMPOSER_MAX_FILE_MENTIONS = 4;
const COMPOSER_MAX_PROMPT_MENTIONS = 4;
const COMPOSER_SKILL_METADATA_MAX_BYTES = 64 * 1024;
const COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS = 180;
const COMPOSER_SKILL_TEXT_MAX_CHARS = 240;
const SESSION_MAX_JSON_BYTES = readPositiveIntEnv('CC_WEB_SESSION_MAX_JSON_BYTES', 10 * 1024 * 1024, { min: 512 * 1024 });
const SESSION_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_LOAD_MAX_BYTES', 32 * 1024 * 1024, { min: SESSION_MAX_JSON_BYTES });
const SESSION_META_FULL_PARSE_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES', 512 * 1024, { min: 64 * 1024 });
@@ -1514,6 +1517,156 @@ function parseSimpleFrontmatter(text) {
return { meta, body: raw.slice(match[0].length) };
}
function stripSimpleYamlComment(line) {
const value = String(line || '');
let quote = '';
let escaped = false;
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\' && quote === '"') {
escaped = true;
continue;
}
if (ch === quote) quote = '';
continue;
}
if (ch === '"' || ch === '\'') {
quote = ch;
continue;
}
if (ch === '#' && (i === 0 || /\s/.test(value[i - 1]))) {
return value.slice(0, i).trimEnd();
}
}
return value;
}
function parseSimpleYamlScalar(rawValue) {
const value = stripSimpleYamlComment(rawValue).trim();
if (!value) return '';
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null' || value === '~') return null;
if (value.startsWith('"') && value.endsWith('"')) {
try { return JSON.parse(value); } catch { return value.slice(1, -1); }
}
if (value.startsWith('\'') && value.endsWith('\'')) {
return value.slice(1, -1).replace(/''/g, '\'');
}
return value;
}
function parseSimpleYamlKeyValue(text) {
const value = String(text || '').trim();
if (!value) return null;
let quote = '';
let escaped = false;
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\' && quote === '"') {
escaped = true;
continue;
}
if (ch === quote) quote = '';
continue;
}
if (ch === '"' || ch === '\'') {
quote = ch;
continue;
}
if (ch !== ':') continue;
const key = value.slice(0, i).trim();
if (!/^[A-Za-z0-9_-]+$/.test(key)) return null;
return { key, value: parseSimpleYamlScalar(value.slice(i + 1)) };
}
return null;
}
function parseOpenAiSkillYaml(text) {
const parsed = { interface: {}, policy: {}, dependencies: { tools: [] } };
let section = '';
let dependencyList = '';
let currentTool = null;
for (const rawLine of String(text || '').split(/\r?\n/)) {
const line = stripSimpleYamlComment(rawLine);
if (!line.trim()) continue;
const indent = line.match(/^\s*/)?.[0]?.length || 0;
const trimmed = line.trim();
if (indent === 0) {
const sectionName = trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : '';
if (['interface', 'policy', 'dependencies'].includes(sectionName)) {
section = sectionName;
dependencyList = '';
currentTool = null;
} else {
section = '';
}
continue;
}
if (!section) continue;
if (section === 'dependencies') {
if (indent === 2 && trimmed === 'tools:') {
dependencyList = 'tools';
currentTool = null;
continue;
}
if (dependencyList === 'tools' && indent >= 4 && trimmed.startsWith('-')) {
currentTool = {};
parsed.dependencies.tools.push(currentTool);
const rest = trimmed.slice(1).trim();
const item = parseSimpleYamlKeyValue(rest);
if (item) currentTool[item.key] = item.value;
continue;
}
if (dependencyList === 'tools' && currentTool && indent >= 6) {
const item = parseSimpleYamlKeyValue(trimmed);
if (item) currentTool[item.key] = item.value;
}
continue;
}
if (indent >= 2) {
const item = parseSimpleYamlKeyValue(trimmed);
if (item) parsed[section][item.key] = item.value;
}
}
return parsed;
}
function normalizeComposerTextValue(value, maxChars = COMPOSER_SKILL_TEXT_MAX_CHARS) {
const text = String(value || '').trim().replace(/\s+/g, ' ');
return text ? truncateTextValue(text, maxChars, '...') : '';
}
function normalizeSkillBrandColor(value) {
const color = String(value || '').trim();
return /^#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?$/.test(color) ? color : '';
}
function normalizeSkillIconUrl(value) {
const url = String(value || '').trim();
if (/^https?:\/\//i.test(url)) return url;
if (/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$/i.test(url) && url.length <= 12000) {
return url;
}
return '';
}
function normalizeComposerKey(value) {
return String(value || '').trim().replace(/^[@$]/, '').toLowerCase();
}
@@ -1551,19 +1704,162 @@ function collectFilesByName(rootDir, fileNames, options = {}) {
return found;
}
function composerSkillRoots() {
function collectFilesByExtension(rootDir, extensions, options = {}) {
const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 4;
const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : 200;
const ignoreDirs = new Set(['node_modules', '.git', 'sessions', 'logs']);
const normalizedExtensions = new Set(
[...extensions].map((ext) => String(ext || '').trim().toLowerCase()).filter(Boolean)
);
const found = [];
function walk(dir, depth) {
if (found.length >= maxFiles || depth > maxDepth) return;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));
for (const entry of entries) {
if (found.length >= maxFiles) return;
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && normalizedExtensions.has(path.extname(entry.name).toLowerCase())) {
found.push(entryPath);
} else if (entry.isDirectory() && !ignoreDirs.has(entry.name)) {
walk(entryPath, depth + 1);
}
}
}
try {
const realRoot = fs.realpathSync(rootDir);
if (fs.statSync(realRoot).isDirectory()) walk(realRoot, 0);
} catch {}
return found;
}
function hasProjectRootMarker(dir) {
return ['.git', '.hg', '.sl'].some((marker) => {
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
});
}
function findNearestProjectRoot(startDir) {
const start = normalizeExistingDirPath(startDir);
if (!start) return null;
let dir = start;
const seen = new Set();
while (dir && !seen.has(dir)) {
seen.add(dir);
if (hasProjectRootMarker(dir)) return dir;
const parent = path.dirname(dir);
if (!parent || parent === dir) break;
dir = parent;
}
return start;
}
function composerProjectSkillRoots(cwd) {
const start = normalizeExistingDirPath(cwd);
const projectRoot = findNearestProjectRoot(start);
if (!start || !projectRoot) return [];
const roots = [];
const codexHome = getCodexHomeDir();
if (codexHome) roots.push(path.join(codexHome, 'skills'));
roots.push(path.join(__dirname, '.codex', 'skills'));
roots.push(path.join(__dirname, '.agents', 'skills'));
let dir = start;
const seen = new Set();
while (dir && !seen.has(dir)) {
seen.add(dir);
roots.push(path.join(dir, '.agents', 'skills'));
roots.push(path.join(dir, '.codex', 'skills'));
if (dir === projectRoot) break;
const parent = path.dirname(dir);
if (!parent || parent === dir) break;
dir = parent;
}
return roots;
}
function loadCodexSkills() {
function composerSkillRoots(options = {}) {
const roots = [];
const session = options.session || (options.sessionId ? loadSession(options.sessionId) : null);
roots.push(...composerProjectSkillRoots(options.cwd || session?.cwd));
const codexHome = getCodexHomeDir();
if (codexHome) roots.push(path.join(codexHome, 'skills'));
const homeDir = normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || '');
if (homeDir) roots.push(path.join(homeDir, '.agents', 'skills'));
roots.push(path.join(__dirname, '.codex', 'skills'));
roots.push(path.join(__dirname, '.agents', 'skills'));
roots.push('/etc/codex/skills');
const seen = new Set();
return roots.filter((root) => {
const key = path.resolve(String(root || ''));
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function normalizeSkillMcpUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.toString() : '';
} catch {
return '';
}
}
function normalizeSkillMcpTool(rawTool) {
if (!rawTool || typeof rawTool !== 'object') return null;
const type = String(rawTool.type || '').trim().toLowerCase();
const value = normalizeMcpServerName(rawTool.value || rawTool.name || rawTool.server);
if (type !== 'mcp' || !isLikelyMcpServerName(value)) return null;
return {
type: 'mcp',
value,
name: value,
server: value,
description: normalizeComposerTextValue(rawTool.description || `MCP server: ${value}`),
transport: normalizeComposerTextValue(rawTool.transport, 80),
url: normalizeSkillMcpUrl(rawTool.url),
};
}
function loadOpenAiSkillMetadata(skillPath) {
const skillDir = path.dirname(skillPath);
const metadataPath = path.join(skillDir, 'agents', 'openai.yaml');
try {
const stat = fs.statSync(metadataPath);
if (!stat.isFile() || stat.size > COMPOSER_SKILL_METADATA_MAX_BYTES) return null;
const parsed = parseOpenAiSkillYaml(fs.readFileSync(metadataPath, 'utf8'));
const iface = parsed.interface || {};
const policy = parsed.policy || {};
const tools = Array.isArray(parsed.dependencies?.tools) ? parsed.dependencies.tools : [];
const mcpTools = tools.map((tool) => normalizeSkillMcpTool(tool)).filter(Boolean);
return {
metadataSource: path.relative(process.cwd(), metadataPath) || metadataPath,
displayName: normalizeComposerTextValue(iface.display_name, 100),
shortDescription: normalizeComposerTextValue(iface.short_description, COMPOSER_SKILL_TEXT_MAX_CHARS),
iconSmall: normalizeSkillIconUrl(iface.icon_small),
iconLarge: normalizeSkillIconUrl(iface.icon_large),
brandColor: normalizeSkillBrandColor(iface.brand_color),
defaultPromptPreview: normalizeComposerTextValue(iface.default_prompt, COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS),
policy: {
allowImplicitInvocation: policy.allow_implicit_invocation !== false,
},
dependencies: { tools: mcpTools },
};
} catch {
return null;
}
}
function loadCodexSkills(options = {}) {
const seen = new Set();
const skills = [];
for (const root of composerSkillRoots()) {
for (const root of composerSkillRoots(options)) {
for (const skillPath of collectFilesByName(root, new Set(['SKILL.md']), { maxDepth: 5, maxFiles: 300 })) {
let text = '';
try { text = fs.readFileSync(skillPath, 'utf8'); } catch { continue; }
@@ -1574,13 +1870,24 @@ function loadCodexSkills() {
const key = normalizeComposerKey(name);
if (!key || seen.has(key)) continue;
seen.add(key);
const metadata = loadOpenAiSkillMetadata(skillPath);
const title = metadata?.displayName || name;
const description = metadata?.shortDescription || String(meta.description || 'Codex skill').trim();
skills.push({
kind: 'skill',
name,
label: `$${name}`,
description: String(meta.description || 'Codex skill').trim(),
title,
description,
insertion: `$${name}`,
source: path.relative(process.cwd(), skillPath) || skillPath,
metadataSource: metadata?.metadataSource || '',
iconSmall: metadata?.iconSmall || '',
iconLarge: metadata?.iconLarge || '',
brandColor: metadata?.brandColor || '',
defaultPromptPreview: metadata?.defaultPromptPreview || '',
policy: metadata?.policy || { allowImplicitInvocation: true },
dependencies: metadata?.dependencies || { tools: [] },
});
}
}
@@ -1627,19 +1934,19 @@ function loadConfiguredPrompts() {
function loadPromptFiles() {
const prompts = [];
for (const root of composerPromptRoots()) {
const files = collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 200 });
try {
if (fs.existsSync(root)) {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.isFile() && /\.(md|txt)$/i.test(entry.name)) files.push(path.join(root, entry.name));
}
}
} catch {}
const files = Array.from(new Set([
...collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 300 }),
...collectFilesByExtension(root, new Set(['.md', '.txt']), { maxDepth: 5, maxFiles: 300 }),
]));
for (const filePath of files) {
let text = '';
try { text = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
const { meta, body } = parseSimpleFrontmatter(text);
const fallbackName = path.basename(filePath).replace(/\.(md|txt)$/i, '');
const baseName = path.basename(filePath).replace(/\.(md|txt)$/i, '');
const relName = normalizeRelativeBrowserPath(path.relative(root, filePath)).replace(/\.(md|txt)$/i, '');
const fallbackName = /^prompt$/i.test(baseName)
? path.basename(path.dirname(filePath))
: relName;
const prompt = normalizePromptRecord({
name: meta.name || fallbackName,
title: meta.title || meta.name || fallbackName,
@@ -1754,17 +2061,69 @@ function mcpServerSuggestion(name, options = {}) {
server,
source: options.source || 'mcp',
itemType: 'server',
state: options.state || (options.source === 'skill-dependency' ? 'declared' : 'configured'),
dependencyOf: options.dependencyOf || '',
transport: options.transport || '',
url: options.url || '',
};
}
function listComposerMcpItems(sessionId) {
function summarizeSkillDependencies(skill) {
const tools = Array.isArray(skill?.dependencies?.tools) ? skill.dependencies.tools : [];
return tools
.filter((tool) => tool?.type === 'mcp' && isLikelyMcpServerName(tool.value || tool.name || tool.server))
.map((tool) => ({
type: 'mcp',
value: normalizeMcpServerName(tool.value || tool.name || tool.server),
name: normalizeMcpServerName(tool.value || tool.name || tool.server),
description: normalizeComposerTextValue(tool.description || ''),
transport: normalizeComposerTextValue(tool.transport || '', 80),
url: normalizeSkillMcpUrl(tool.url || ''),
state: 'declared',
}));
}
function summarizeSkillForMention(skill, configuredMcpNames = new Set()) {
const dependencies = summarizeSkillDependencies(skill).map((dependency) => ({
...dependency,
state: configuredMcpNames.has(normalizeMcpServerName(dependency.value)) ? 'configured' : 'declared',
}));
return {
kind: 'skill',
name: skill.name,
label: `$${skill.name}`,
title: skill.title || skill.name,
description: skill.description || '',
source: skill.source || '',
metadataSource: skill.metadataSource || '',
iconSmall: skill.iconSmall || '',
iconLarge: skill.iconLarge || '',
brandColor: skill.brandColor || '',
defaultPromptPreview: skill.defaultPromptPreview || '',
dependencies,
};
}
function listComposerMcpItems(options = {}) {
const sessionId = typeof options === 'string' ? options : options.sessionId;
const agent = typeof options === 'object' ? options.agent : '';
const skills = typeof options === 'object' && Array.isArray(options.skills)
? options.skills
: isCodexLikeAgent(agent)
? loadCodexSkills(options)
: [];
const items = [];
const seen = new Set();
const configured = new Set();
const push = (item) => {
if (!item?.server && !item?.name) return;
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
if (seen.has(key)) return;
seen.add(key);
if (item.kind === 'mcp' && item.itemType === 'server' && item.state !== 'declared') {
const server = normalizeMcpServerName(item.server || item.name);
if (server) configured.add(server);
}
items.push(item);
};
@@ -1794,6 +2153,20 @@ function listComposerMcpItems(sessionId) {
itemType: 'tool',
});
}
for (const skill of skills) {
for (const tool of summarizeSkillDependencies(skill)) {
const server = normalizeMcpServerName(tool.value || tool.name || tool.server);
if (!server || configured.has(server)) continue;
push(mcpServerSuggestion(server, {
source: 'skill-dependency',
state: 'declared',
dependencyOf: skill.name,
description: tool.description || `MCP server: ${server}`,
transport: tool.transport || '',
url: tool.url || '',
}));
}
}
return items;
}
@@ -1864,8 +2237,9 @@ function listComposerFileSuggestions(sessionId, query) {
return items.slice(0, COMPOSER_SUGGESTION_LIMIT);
}
function listComposerSuggestions(trigger, query, sessionId, agent) {
const mcpItems = filterComposerItems(listComposerMcpItems(sessionId), query);
function listComposerSuggestions(trigger, query, sessionId, agent, session = null) {
const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : [];
const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, agent, skills: skillItems }), query);
if (trigger === '/') {
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
kind: 'command',
@@ -1877,7 +2251,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent) {
return mergeComposerSuggestionGroups(commands, mcpItems);
}
if (trigger === '$') {
const skills = isCodexLikeAgent(agent) ? filterComposerItems(loadCodexSkills(), query) : [];
const skills = filterComposerItems(skillItems, query);
return mergeComposerSuggestionGroups(mcpItems, skills);
}
if (trigger === '@') {
@@ -1905,7 +2279,8 @@ function handleComposerSuggestions(ws, msg) {
}
const sessionId = sanitizeId(msg.sessionId || '');
const agent = normalizeAgent(msg.agent);
const items = listComposerSuggestions(trigger, query, sessionId, agent);
const session = sessionId ? loadSession(sessionId) : null;
const items = listComposerSuggestions(trigger, query, sessionId, agent, session);
return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items });
}
@@ -1969,13 +2344,16 @@ function resolveComposerDecorators(text, session, agent) {
}
const promptsByName = getComposerPromptMap();
const skillsByName = new Map(loadCodexSkills().map((skill) => [normalizeComposerKey(skill.name), skill]));
const skillsByName = new Map(loadCodexSkills({ session }).map((skill) => [normalizeComposerKey(skill.name), skill]));
const mentions = [];
const promptBlocks = [];
const fileBlocks = [];
const seenPromptNames = new Set();
const seenFilePaths = new Set();
const seenSkillNames = new Set();
const configuredMcpNames = new Set(listComposerMcpItems({ sessionId: session?.id, agent, skills: [...skillsByName.values()] })
.filter((item) => item.kind === 'mcp' && item.itemType === 'server' && item.state !== 'declared')
.map((item) => normalizeMcpServerName(item.server || item.name)));
const re = /(^|\s)([@$])([^\s]+)/g;
let match;
@@ -1989,7 +2367,7 @@ function resolveComposerDecorators(text, session, agent) {
const skill = skillsByName.get(key);
if (skill && !seenSkillNames.has(key)) {
seenSkillNames.add(key);
mentions.push({ kind: 'skill', name: skill.name, label: `$${skill.name}`, source: skill.source || '' });
mentions.push(summarizeSkillForMention(skill, configuredMcpNames));
}
continue;
}