feat: enrich Codex skill metadata display
This commit is contained in:
116
public/app.js
116
public/app.js
@@ -3587,6 +3587,120 @@
|
|||||||
}, ttl);
|
}, 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, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {}) {
|
function createMsgElement(role, content, attachments = [], meta = {}) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
const isCrossConversation = !!meta.crossConversation;
|
const isCrossConversation = !!meta.crossConversation;
|
||||||
@@ -3705,6 +3819,8 @@
|
|||||||
if (attachments.length > 0) {
|
if (attachments.length > 0) {
|
||||||
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
|
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
|
||||||
}
|
}
|
||||||
|
const mentionsStrip = renderComposerMentionsStrip(meta);
|
||||||
|
if (mentionsStrip) bubble.appendChild(mentionsStrip);
|
||||||
} else {
|
} else {
|
||||||
renderAssistantContent(bubble, content);
|
renderAssistantContent(bubble, content);
|
||||||
if (attachments.length > 0) {
|
if (attachments.length > 0) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
document.documentElement.dataset.dividerTime = dividerTime;
|
document.documentElement.dataset.dividerTime = dividerTime;
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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/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="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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
123
public/style.css
123
public/style.css
@@ -1903,6 +1903,105 @@ body.session-loading-active {
|
|||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
padding-right: 42px;
|
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 {
|
.msg.user.codex-steer-message .msg-bubble {
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -2051,6 +2150,30 @@ body.session-loading-active {
|
|||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
color: var(--text-primary);
|
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 {
|
.note-meta {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
color: var(--note-accent);
|
color: var(--note-accent);
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ function connectWs(port, password) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||||
const messages = [];
|
const messages = [];
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'auth', password }));
|
ws.send(JSON.stringify({ type: 'auth', password }));
|
||||||
@@ -141,10 +142,20 @@ function connectWs(port, password) {
|
|||||||
ws.on('message', (buf) => {
|
ws.on('message', (buf) => {
|
||||||
const msg = JSON.parse(String(buf));
|
const msg = JSON.parse(String(buf));
|
||||||
messages.push(msg);
|
messages.push(msg);
|
||||||
if (msg.type === 'auth_result' && msg.success) resolve({ ws, messages, token: msg.token });
|
if (msg.type === 'auth_result' && msg.success) {
|
||||||
if (msg.type === 'auth_result' && !msg.success) reject(new Error('Auth failed'));
|
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');
|
assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
|
||||||
const menuBlock = source.slice(menuStart, menuEnd);
|
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(/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() {
|
async function main() {
|
||||||
@@ -537,6 +550,39 @@ async function main() {
|
|||||||
'',
|
'',
|
||||||
'Use this only in regression tests.',
|
'Use this only in regression tests.',
|
||||||
].join('\n'));
|
].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({
|
fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({
|
||||||
prompts: [
|
prompts: [
|
||||||
{
|
{
|
||||||
@@ -618,6 +664,18 @@ async function main() {
|
|||||||
|
|
||||||
const codexInitCwd = path.join(tempRoot, 'codex-space');
|
const codexInitCwd = path.join(tempRoot, 'codex-space');
|
||||||
mkdirp(codexInitCwd);
|
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.');
|
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
|
||||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
|
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);
|
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' }));
|
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');
|
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');
|
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' }));
|
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');
|
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');
|
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' }));
|
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');
|
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');
|
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' }));
|
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');
|
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');
|
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({
|
ws.send(JSON.stringify({
|
||||||
type: 'message',
|
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,
|
sessionId: codexSession.sessionId,
|
||||||
mode: 'plan',
|
mode: 'plan',
|
||||||
agent: 'codex',
|
agent: 'codex',
|
||||||
@@ -694,16 +770,23 @@ async function main() {
|
|||||||
const composerExpanded = await nextMessage(messages, ws, (msg) => (
|
const composerExpanded = await nextMessage(messages, ws, (msg) => (
|
||||||
msg.type === 'text_delta' &&
|
msg.type === 'text_delta' &&
|
||||||
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
|
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
|
||||||
|
/BEGIN CC-WEB PROMPT: quick-note/.test(msg.text || '') &&
|
||||||
/Composer file context body/.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(/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);
|
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 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, '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 === '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 === '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 === '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, {
|
const mcpList = await callInternalMcp(port, internalMcpToken, {
|
||||||
tool: 'ccweb_list_conversations',
|
tool: 'ccweb_list_conversations',
|
||||||
|
|||||||
426
server.js
426
server.js
@@ -48,6 +48,9 @@ const COMPOSER_SUGGESTION_LIMIT = 20;
|
|||||||
const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024;
|
const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024;
|
||||||
const COMPOSER_MAX_FILE_MENTIONS = 4;
|
const COMPOSER_MAX_FILE_MENTIONS = 4;
|
||||||
const COMPOSER_MAX_PROMPT_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_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_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 });
|
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) };
|
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) {
|
function normalizeComposerKey(value) {
|
||||||
return String(value || '').trim().replace(/^[@$]/, '').toLowerCase();
|
return String(value || '').trim().replace(/^[@$]/, '').toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -1551,19 +1704,162 @@ function collectFilesByName(rootDir, fileNames, options = {}) {
|
|||||||
return found;
|
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 roots = [];
|
||||||
const codexHome = getCodexHomeDir();
|
let dir = start;
|
||||||
if (codexHome) roots.push(path.join(codexHome, 'skills'));
|
const seen = new Set();
|
||||||
roots.push(path.join(__dirname, '.codex', 'skills'));
|
while (dir && !seen.has(dir)) {
|
||||||
roots.push(path.join(__dirname, '.agents', 'skills'));
|
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;
|
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 seen = new Set();
|
||||||
const skills = [];
|
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 })) {
|
for (const skillPath of collectFilesByName(root, new Set(['SKILL.md']), { maxDepth: 5, maxFiles: 300 })) {
|
||||||
let text = '';
|
let text = '';
|
||||||
try { text = fs.readFileSync(skillPath, 'utf8'); } catch { continue; }
|
try { text = fs.readFileSync(skillPath, 'utf8'); } catch { continue; }
|
||||||
@@ -1574,13 +1870,24 @@ function loadCodexSkills() {
|
|||||||
const key = normalizeComposerKey(name);
|
const key = normalizeComposerKey(name);
|
||||||
if (!key || seen.has(key)) continue;
|
if (!key || seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
const metadata = loadOpenAiSkillMetadata(skillPath);
|
||||||
|
const title = metadata?.displayName || name;
|
||||||
|
const description = metadata?.shortDescription || String(meta.description || 'Codex skill').trim();
|
||||||
skills.push({
|
skills.push({
|
||||||
kind: 'skill',
|
kind: 'skill',
|
||||||
name,
|
name,
|
||||||
label: `$${name}`,
|
label: `$${name}`,
|
||||||
description: String(meta.description || 'Codex skill').trim(),
|
title,
|
||||||
|
description,
|
||||||
insertion: `$${name}`,
|
insertion: `$${name}`,
|
||||||
source: path.relative(process.cwd(), skillPath) || skillPath,
|
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() {
|
function loadPromptFiles() {
|
||||||
const prompts = [];
|
const prompts = [];
|
||||||
for (const root of composerPromptRoots()) {
|
for (const root of composerPromptRoots()) {
|
||||||
const files = collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 200 });
|
const files = Array.from(new Set([
|
||||||
try {
|
...collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 300 }),
|
||||||
if (fs.existsSync(root)) {
|
...collectFilesByExtension(root, new Set(['.md', '.txt']), { maxDepth: 5, maxFiles: 300 }),
|
||||||
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 {}
|
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
let text = '';
|
let text = '';
|
||||||
try { text = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
|
try { text = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
|
||||||
const { meta, body } = parseSimpleFrontmatter(text);
|
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({
|
const prompt = normalizePromptRecord({
|
||||||
name: meta.name || fallbackName,
|
name: meta.name || fallbackName,
|
||||||
title: meta.title || meta.name || fallbackName,
|
title: meta.title || meta.name || fallbackName,
|
||||||
@@ -1754,17 +2061,69 @@ function mcpServerSuggestion(name, options = {}) {
|
|||||||
server,
|
server,
|
||||||
source: options.source || 'mcp',
|
source: options.source || 'mcp',
|
||||||
itemType: 'server',
|
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 items = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
const configured = new Set();
|
||||||
const push = (item) => {
|
const push = (item) => {
|
||||||
if (!item?.server && !item?.name) return;
|
if (!item?.server && !item?.name) return;
|
||||||
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
|
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
|
||||||
if (seen.has(key)) return;
|
if (seen.has(key)) return;
|
||||||
seen.add(key);
|
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);
|
items.push(item);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1794,6 +2153,20 @@ function listComposerMcpItems(sessionId) {
|
|||||||
itemType: 'tool',
|
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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1864,8 +2237,9 @@ function listComposerFileSuggestions(sessionId, query) {
|
|||||||
return items.slice(0, COMPOSER_SUGGESTION_LIMIT);
|
return items.slice(0, COMPOSER_SUGGESTION_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listComposerSuggestions(trigger, query, sessionId, agent) {
|
function listComposerSuggestions(trigger, query, sessionId, agent, session = null) {
|
||||||
const mcpItems = filterComposerItems(listComposerMcpItems(sessionId), query);
|
const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : [];
|
||||||
|
const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, agent, skills: skillItems }), query);
|
||||||
if (trigger === '/') {
|
if (trigger === '/') {
|
||||||
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
|
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
|
||||||
kind: 'command',
|
kind: 'command',
|
||||||
@@ -1877,7 +2251,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent) {
|
|||||||
return mergeComposerSuggestionGroups(commands, mcpItems);
|
return mergeComposerSuggestionGroups(commands, mcpItems);
|
||||||
}
|
}
|
||||||
if (trigger === '$') {
|
if (trigger === '$') {
|
||||||
const skills = isCodexLikeAgent(agent) ? filterComposerItems(loadCodexSkills(), query) : [];
|
const skills = filterComposerItems(skillItems, query);
|
||||||
return mergeComposerSuggestionGroups(mcpItems, skills);
|
return mergeComposerSuggestionGroups(mcpItems, skills);
|
||||||
}
|
}
|
||||||
if (trigger === '@') {
|
if (trigger === '@') {
|
||||||
@@ -1905,7 +2279,8 @@ function handleComposerSuggestions(ws, msg) {
|
|||||||
}
|
}
|
||||||
const sessionId = sanitizeId(msg.sessionId || '');
|
const sessionId = sanitizeId(msg.sessionId || '');
|
||||||
const agent = normalizeAgent(msg.agent);
|
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 });
|
return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1969,13 +2344,16 @@ function resolveComposerDecorators(text, session, agent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptsByName = getComposerPromptMap();
|
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 mentions = [];
|
||||||
const promptBlocks = [];
|
const promptBlocks = [];
|
||||||
const fileBlocks = [];
|
const fileBlocks = [];
|
||||||
const seenPromptNames = new Set();
|
const seenPromptNames = new Set();
|
||||||
const seenFilePaths = new Set();
|
const seenFilePaths = new Set();
|
||||||
const seenSkillNames = 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;
|
const re = /(^|\s)([@$])([^\s]+)/g;
|
||||||
let match;
|
let match;
|
||||||
@@ -1989,7 +2367,7 @@ function resolveComposerDecorators(text, session, agent) {
|
|||||||
const skill = skillsByName.get(key);
|
const skill = skillsByName.get(key);
|
||||||
if (skill && !seenSkillNames.has(key)) {
|
if (skill && !seenSkillNames.has(key)) {
|
||||||
seenSkillNames.add(key);
|
seenSkillNames.add(key);
|
||||||
mentions.push({ kind: 'skill', name: skill.name, label: `$${skill.name}`, source: skill.source || '' });
|
mentions.push(summarizeSkillForMention(skill, configuredMcpNames));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user