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

View File

@@ -3587,6 +3587,120 @@
}, ttl);
}
function normalizeMentionList(value) {
return Array.isArray(value) ? value.filter((item) => item && typeof item === 'object') : [];
}
function escapeHtmlAttr(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function shortPreviewText(text, maxLength = 140) {
const normalized = String(text || '').trim().replace(/\s+/g, ' ');
if (!normalized) return '';
return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 3))}...` : normalized;
}
function mentionDependencyStateLabel(state) {
return state === 'configured' ? '已配置' : state === 'declared' ? '未配置' : '';
}
function mentionDependencyLabel(dep) {
const name = dep?.value || dep?.name || dep?.server || '';
const state = mentionDependencyStateLabel(dep?.state);
return state ? `${name} · ${state}` : name;
}
function buildMentionTooltip(mention) {
const lines = [];
const title = mention.title || mention.name || mention.label || '';
const description = shortPreviewText(mention.description || '');
if (title) lines.push(title);
if (description) lines.push(description);
if (mention.defaultPromptPreview) lines.push(`默认提示: ${shortPreviewText(mention.defaultPromptPreview, 180)}`);
const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : [];
for (const dep of dependencies.slice(0, 4)) {
const label = mentionDependencyLabel(dep);
if (label) lines.push(`MCP: ${label}`);
}
return lines.join('\n');
}
function createMentionChip(mention) {
const chip = document.createElement('div');
const kind = String(mention.kind || '').trim() || 'mention';
chip.className = `msg-mention-chip kind-${kind}`;
if (mention.brandColor) chip.style.setProperty('--mention-accent', mention.brandColor);
const tooltip = buildMentionTooltip(mention);
if (tooltip) chip.title = tooltip;
if (kind === 'skill') {
const badge = document.createElement('span');
badge.className = 'msg-mention-badge';
if (mention.iconSmall && /^https?:\/\//i.test(String(mention.iconSmall))) {
const img = document.createElement('img');
img.src = mention.iconSmall;
img.alt = mention.title || mention.name || 'skill';
img.loading = 'lazy';
badge.appendChild(img);
} else {
badge.textContent = 'Skill';
}
chip.appendChild(badge);
}
const body = document.createElement('div');
body.className = 'msg-mention-body';
const title = document.createElement('div');
title.className = 'msg-mention-title';
if (kind === 'skill') {
title.textContent = mention.title || mention.name || mention.label || '';
} else if (kind === 'prompt') {
title.textContent = mention.title || mention.label || mention.name || '';
} else {
title.textContent = mention.label || mention.name || '';
}
body.appendChild(title);
const descriptionText = mention.description || (kind === 'prompt' ? 'Prompt 模板' : '');
if (descriptionText) {
const desc = document.createElement('div');
desc.className = 'msg-mention-desc';
desc.textContent = shortPreviewText(descriptionText, kind === 'skill' ? 92 : 72);
body.appendChild(desc);
}
const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : [];
if (dependencies.length > 0) {
const meta = document.createElement('div');
meta.className = 'msg-mention-meta';
dependencies.slice(0, 2).forEach((dep) => {
const pill = document.createElement('span');
pill.className = `msg-mention-pill state-${dep.state || 'declared'}`;
pill.textContent = mentionDependencyLabel(dep);
meta.appendChild(pill);
});
body.appendChild(meta);
}
chip.appendChild(body);
return chip;
}
function renderComposerMentionsStrip(meta) {
const mentions = normalizeMentionList(meta?.composerMentions);
if (mentions.length === 0) return null;
const wrap = document.createElement('div');
wrap.className = 'msg-mentions';
mentions.forEach((mention) => wrap.appendChild(createMentionChip(mention)));
return wrap;
}
function createMsgElement(role, content, attachments = [], meta = {}) {
const div = document.createElement('div');
const isCrossConversation = !!meta.crossConversation;
@@ -3705,6 +3819,8 @@
if (attachments.length > 0) {
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
}
const mentionsStrip = renderComposerMentionsStrip(meta);
if (mentionsStrip) bubble.appendChild(mentionsStrip);
} else {
renderAssistantContent(bubble, content);
if (attachments.length > 0) {

View File

@@ -14,7 +14,7 @@
document.documentElement.dataset.dividerTime = dividerTime;
})();
</script>
<link rel="stylesheet" href="style.css?v=20260616-child-agent-close-state">
<link rel="stylesheet" href="style.css?v=20260617-skill-openai-yaml">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head>
<body>
@@ -150,6 +150,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="app.js?v=20260616-child-agent-close-state"></script>
<script src="app.js?v=20260617-skill-openai-yaml"></script>
</body>
</html>

View File

@@ -1903,6 +1903,105 @@ body.session-loading-active {
border-bottom-right-radius: 4px;
padding-right: 42px;
}
.msg-mentions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.msg-mention-chip {
--mention-accent: rgba(255, 255, 255, 0.34);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 5px;
max-width: 100%;
min-width: 0;
padding: 7px 9px 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
color: inherit;
}
.msg-mention-chip.kind-skill {
width: min(290px, 100%);
}
.msg-mention-chip.kind-prompt,
.msg-mention-chip.kind-file {
background: rgba(255, 255, 255, 0.08);
}
.msg-mention-badge {
min-width: 0;
height: 18px;
width: auto;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-start;
padding: 0 7px;
border-radius: 6px;
background: color-mix(in srgb, var(--mention-accent) 68%, rgba(255, 255, 255, 0.08));
color: #fff;
font-size: 9px;
font-weight: 800;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0;
overflow: hidden;
}
.msg-mention-badge img {
display: block;
width: 18px;
height: 18px;
margin: 0 -7px;
object-fit: cover;
}
.msg-mention-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.msg-mention-title {
font-size: 12px;
font-weight: 800;
line-height: 1.25;
overflow-wrap: anywhere;
}
.msg-mention-desc {
font-size: 11px;
line-height: 1.3;
color: rgba(255, 255, 255, 0.84);
overflow-wrap: anywhere;
}
.msg-mention-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.msg-mention-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
font-size: 10px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
}
.msg-mention-pill.state-configured {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(93, 138, 84, 0.24);
}
.msg-mention-pill.state-declared {
background: rgba(255, 255, 255, 0.06);
}
.msg.user.codex-steer-message .msg-bubble {
padding-bottom: 10px;
}
@@ -2051,6 +2150,30 @@ body.session-loading-active {
border-bottom-left-radius: 4px;
color: var(--text-primary);
}
.msg.assistant .msg-mention-chip {
border-color: rgba(48, 62, 82, 0.14);
background: rgba(91, 126, 161, 0.07);
color: var(--text-primary);
}
.msg.assistant .msg-mention-chip.kind-prompt,
.msg.assistant .msg-mention-chip.kind-file {
background: rgba(91, 126, 161, 0.05);
}
.msg.assistant .msg-mention-badge {
color: #fff;
}
.msg.assistant .msg-mention-desc {
color: var(--text-secondary);
}
.msg.assistant .msg-mention-pill {
border-color: rgba(48, 62, 82, 0.14);
background: rgba(91, 126, 161, 0.08);
color: var(--text-secondary);
}
.msg.assistant .msg-mention-pill.state-configured {
background: rgba(93, 138, 84, 0.14);
color: var(--success);
}
.note-meta {
margin-bottom: 6px;
color: var(--note-accent);

View File

@@ -134,6 +134,7 @@ function connectWs(port, password) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const messages = [];
let settled = false;
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'auth', password }));
@@ -141,10 +142,20 @@ function connectWs(port, password) {
ws.on('message', (buf) => {
const msg = JSON.parse(String(buf));
messages.push(msg);
if (msg.type === 'auth_result' && msg.success) resolve({ ws, messages, token: msg.token });
if (msg.type === 'auth_result' && !msg.success) reject(new Error('Auth failed'));
if (msg.type === 'auth_result' && msg.success) {
settled = true;
resolve({ ws, messages, token: msg.token });
}
if (msg.type === 'auth_result' && !msg.success) {
settled = true;
reject(new Error('Auth failed'));
}
});
ws.on('error', (err) => {
if (settled) return;
settled = true;
reject(err);
});
ws.on('error', reject);
});
}
@@ -500,6 +511,8 @@ function assertFrontendComposerMcpContract() {
assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
const menuBlock = source.slice(menuStart, menuEnd);
assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels');
assert(source.includes('function renderComposerMentionsStrip(meta)'), 'Frontend should define composer mention strip renderer');
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
}
async function main() {
@@ -537,6 +550,39 @@ async function main() {
'',
'Use this only in regression tests.',
].join('\n'));
mkdirp(path.join(skillDir, 'agents'));
fs.writeFileSync(path.join(skillDir, 'agents', 'openai.yaml'), [
'interface:',
' display_name: "Regression Docs"',
' short_description: "Regression skill metadata for composer suggestions."',
' brand_color: "#2f6f64"',
' default_prompt: "Use Regression Docs for regression metadata coverage."',
'',
'dependencies:',
' tools:',
' - type: "mcp"',
' value: "openaiDeveloperDocs"',
' description: "Regression docs MCP server"',
' transport: "streamable_http"',
' url: "https://developers.openai.com/mcp"',
].join('\n'));
const codexPromptsDir = path.join(homeDir, '.codex', 'prompts');
mkdirp(path.join(codexPromptsDir, 'nested-tool'));
fs.writeFileSync(path.join(codexPromptsDir, 'quick-note.md'), [
'---',
'title: Quick Note',
'description: Prompt file shortcut from ~/.codex/prompts.',
'---',
'',
'Prompt body from @quick-note.',
].join('\n'));
fs.writeFileSync(path.join(codexPromptsDir, 'nested-tool', 'prompt.md'), [
'---',
'description: Directory-style prompt shortcut.',
'---',
'',
'Prompt body from @nested-tool.',
].join('\n'));
fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({
prompts: [
{
@@ -618,6 +664,18 @@ async function main() {
const codexInitCwd = path.join(tempRoot, 'codex-space');
mkdirp(codexInitCwd);
const projectSkillDir = path.join(codexInitCwd, '.agents', 'skills', 'project-skill');
mkdirp(projectSkillDir);
fs.writeFileSync(path.join(projectSkillDir, 'SKILL.md'), [
'---',
'name: project-skill',
'description: Project-scoped skill for composer suggestions.',
'---',
'',
'# Project Skill',
'',
'Use this only in regression tests.',
].join('\n'));
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd);
@@ -667,15 +725,33 @@ async function main() {
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
const metadataSkill = skillComposer.items.find((item) => item.kind === 'skill' && item.name === 'regression-skill');
assert(metadataSkill?.title === 'Regression Docs', 'Composer skill suggestions should expose openai.yaml display_name');
assert(/metadata coverage/.test(metadataSkill?.defaultPromptPreview || ''), 'Composer skill suggestions should expose default prompt preview');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-project-skill', trigger: '$', query: 'project', sessionId: codexSession.sessionId, agent: 'codex' }));
const projectSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-project-skill');
assert(projectSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'project-skill'), 'Composer skill suggestions should include project-scoped skill from session cwd');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp');
assert(skillMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer skill trigger suggestions should include ccweb MCP tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-declared-mcp', trigger: '$', query: 'openai', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillDeclaredMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-declared-mcp');
assert(skillDeclaredMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'openaiDeveloperDocs' && item.state === 'declared'), 'Composer skill trigger suggestions should include declared MCP dependencies from openai.yaml');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt');
assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-file', trigger: '@', query: 'quick', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptFileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-file');
assert(promptFileComposer.items.some((item) => item.kind === 'prompt' && item.name === 'quick-note'), 'Composer prompt suggestions should include ~/.codex/prompts/*.md shortcuts');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-dir', trigger: '@', query: 'nested', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptDirComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-dir');
assert(promptDirComposer.items.some((item) => item.kind === 'prompt' && item.name === 'nested-tool'), 'Composer prompt suggestions should include ~/.codex/prompts/<name>/prompt.md shortcuts');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-mcp');
assert(promptMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer prompt trigger suggestions should include ccweb MCP tools');
@@ -686,7 +762,7 @@ async function main() {
ws.send(JSON.stringify({
type: 'message',
text: '@shipit @context.txt $regression-skill run composer regression',
text: '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression',
sessionId: codexSession.sessionId,
mode: 'plan',
agent: 'codex',
@@ -694,16 +770,23 @@ async function main() {
const composerExpanded = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
/BEGIN CC-WEB PROMPT: quick-note/.test(msg.text || '') &&
/Composer file context body/.test(msg.text || '')
));
assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content');
assert(/Prompt body from @quick-note/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand ~/.codex/prompts prompt shortcuts');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @context.txt $regression-skill run composer regression');
const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression');
assert(storedComposerMessage, 'Composer message should persist original user text');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'quick-note'), 'Composer message should persist ~/.codex/prompts prompt mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'project-skill'), 'Composer message should persist project-scoped skill mention metadata');
const storedRegressionSkillMention = storedComposerMessage.composerMentions?.find((mention) => mention.kind === 'skill' && mention.name === 'regression-skill');
assert(storedRegressionSkillMention?.title === 'Regression Docs', 'Stored skill mention should persist display title from openai.yaml');
assert(storedRegressionSkillMention?.dependencies?.some((dep) => dep.value === 'openaiDeveloperDocs' && dep.state === 'declared'), 'Stored skill mention should persist MCP dependency metadata');
const mcpList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',

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;
}